-
Notifications
You must be signed in to change notification settings - Fork 448
Expand file tree
/
Copy pathnotify_comment.go
More file actions
861 lines (763 loc) · 46.9 KB
/
Copy pathnotify_comment.go
File metadata and controls
861 lines (763 loc) · 46.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
package workflow
import (
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow/compilerenv"
)
var notifyCommentLog = logger.New("workflow:notify_comment")
// buildConclusionJob creates a job that handles workflow completion tasks
// This job is generated when safe-outputs are configured and handles:
// - Updating status comments (if status-comment: true)
// - Processing noop messages
// - Handling agent failures
// - Recording missing tools
// This job runs when:
// 1. always() - runs even if agent fails
// 2. Agent job was not skipped
// 3. NO add_comment output was produced by the agent (avoids duplicate updates)
// This job depends on all safe output jobs to ensure it runs last
func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, safeOutputJobNames []string) (*Job, error) {
notifyCommentLog.Printf("Building conclusion job: main_job=%s, safe_output_jobs_count=%d", mainJobName, len(safeOutputJobNames))
// Always create this job when safe-outputs exist (because noop is always enabled)
// This ensures noop messages can be handled even without reactions
if data.SafeOutputs == nil {
notifyCommentLog.Printf("Skipping job: no safe-outputs configured")
return nil, nil // No safe-outputs configured, no need for conclusion job
}
// Build the job steps
var steps []string
// Add setup step to copy scripts
setupActionRef := c.resolveActionReference("./actions/setup", data)
if setupActionRef != "" || c.actionMode.IsScript() {
// For dev mode (local action path), checkout the actions folder first
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
// Notify comment job doesn't need project support
// Conclusion/notify job depends on activation, reuse its trace ID
notifyTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName)
notifyParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName)
steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, notifyTraceID, notifyParentSpanID)...)
}
// Add GitHub App token minting step if app is configured
if data.SafeOutputs.GitHubApp != nil {
// Compute permissions based on configured safe outputs (principle of least privilege)
permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs)
// For workflow_call relay workflows, scope the token to the platform repo name only
// (not the full slug) because actions/create-github-app-token expects repo names
// without the owner prefix when `owner` is also set.
var appTokenFallbackRepo string
if hasWorkflowCallTrigger(data.On) {
appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}"
}
steps = append(steps, c.buildGitHubAppTokenMintStepForRepository(
data.SafeOutputs.GitHubApp,
permissions,
appTokenFallbackRepo,
inferSingleCheckoutRepositoryForGitHubAppOwner(data),
)...)
}
// Add artifact download steps once (shared by noop and conclusion steps).
// In workflow_call context, use the per-invocation prefix to avoid artifact name clashes.
steps = append(steps, buildAgentOutputDownloadSteps(artifactPrefixExprForDownstreamJob(data), c.getActionPin)...)
// Package a compact usage artifact so forecasting/analytics commands can fetch
// token usage and aw_info without downloading full agent artifacts.
steps = append(steps, buildUsageArtifactUploadSteps(artifactPrefixExprForDownstreamJob(data), c.getActionPin)...)
// Add noop processing step if noop is configured.
// This single step replaces the former two-step "Process No-Op Messages" + "Handle No-Op Message"
// sequence: handle_noop_message.cjs now loads agent output directly (no cross-step dep).
if data.SafeOutputs.NoOp != nil {
// Build custom environment variables for the merged noop step
var noopEnvVars []string
noopEnvVars = append(noopEnvVars, buildTemplatableIntEnvVar("GH_AW_NOOP_MAX", data.SafeOutputs.NoOp.Max)...)
// Add workflow metadata for consistency
noopEnvVars = append(noopEnvVars, buildWorkflowMetadataEnvVarsWithTrackerID(data.Name, data.Source, data.TrackerID, buildLocalWorkflowSourceURL(c.markdownPath))...)
// Agent conclusion and run URL are used to decide whether to post to the runs issue
noopEnvVars = append(noopEnvVars, " GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n")
noopEnvVars = append(noopEnvVars, fmt.Sprintf(" GH_AW_AGENT_CONCLUSION: ${{ needs.%s.result }}\n", mainJobName))
noopEnvVars = append(noopEnvVars, buildTemplatableBoolEnvVar("GH_AW_NOOP_REPORT_AS_ISSUE", data.SafeOutputs.NoOp.ReportAsIssue)...)
if data.SafeOutputs.NoOp.ReportAsIssue == nil {
noopEnvVars = append(noopEnvVars, " GH_AW_NOOP_REPORT_AS_ISSUE: \"true\"\n")
}
// AIC, ambient context, and workflow ID are used to enrich noop comments with
// AI Credits cost, context size metrics, and a history search link.
noopEnvVars = append(noopEnvVars, fmt.Sprintf(" GH_AW_AIC: ${{ needs.%s.outputs.aic }}\n", mainJobName))
if IsDetectionJobEnabled(data.SafeOutputs) {
noopEnvVars = append(noopEnvVars, fmt.Sprintf(" GH_AW_THREAT_DETECTION_AIC: ${{ needs.%s.outputs.aic }}\n", constants.DetectionJobName))
}
noopEnvVars = append(noopEnvVars, fmt.Sprintf(" GH_AW_AMBIENT_CONTEXT: ${{ needs.%s.outputs.ambient_context }}\n", mainJobName))
if data.WorkflowID != "" {
noopEnvVars = append(noopEnvVars, fmt.Sprintf(" GH_AW_WORKFLOW_ID: %q\n", data.WorkflowID))
}
// Build the merged noop step (without artifact downloads - already added above)
noopSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Process no-op messages",
StepID: "noop",
MainJobName: mainJobName,
CustomEnvVars: noopEnvVars,
ScriptFile: "handle_noop_message.cjs",
CustomToken: data.SafeOutputs.NoOp.GitHubToken,
})
steps = append(steps, noopSteps...)
}
// Add detection runs logging step if threat detection is enabled.
// This posts a comment to the "[aw] Detection Runs" tracking issue whenever
// the detection job produces a warning or failure conclusion.
if IsDetectionJobEnabled(data.SafeOutputs) {
var detectionRunsEnvVars []string
detectionRunsEnvVars = append(detectionRunsEnvVars, buildWorkflowMetadataEnvVarsWithTrackerID(data.Name, data.Source, data.TrackerID, buildLocalWorkflowSourceURL(c.markdownPath))...)
detectionRunsEnvVars = append(detectionRunsEnvVars, " GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n")
detectionRunsEnvVars = append(detectionRunsEnvVars, fmt.Sprintf(" GH_AW_DETECTION_CONCLUSION: ${{ needs.%s.outputs.detection_conclusion }}\n", constants.DetectionJobName))
detectionRunsEnvVars = append(detectionRunsEnvVars, fmt.Sprintf(" GH_AW_DETECTION_REASON: ${{ needs.%s.outputs.detection_reason }}\n", constants.DetectionJobName))
detectionRunsSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Log detection run",
StepID: "detection_runs",
MainJobName: mainJobName,
CustomEnvVars: detectionRunsEnvVars,
ScriptFile: "handle_detection_runs.cjs",
})
steps = append(steps, detectionRunsSteps...)
notifyCommentLog.Print("Added detection runs logging step to conclusion job")
}
// Add missing_tool processing step if missing-tool is configured
if data.SafeOutputs.MissingTool != nil {
// Build custom environment variables specific to missing-tool
var missingToolEnvVars []string
missingToolEnvVars = append(missingToolEnvVars, buildTemplatableIntEnvVar("GH_AW_MISSING_TOOL_MAX", data.SafeOutputs.MissingTool.Max)...)
// Add create-issue configuration
missingToolEnvVars = append(missingToolEnvVars, buildTemplatableBoolEnvVar("GH_AW_MISSING_TOOL_CREATE_ISSUE", data.SafeOutputs.MissingTool.CreateIssue)...)
// Add title-prefix configuration
if data.SafeOutputs.MissingTool.TitlePrefix != "" {
missingToolEnvVars = append(missingToolEnvVars, fmt.Sprintf(" GH_AW_MISSING_TOOL_TITLE_PREFIX: %q\n", data.SafeOutputs.MissingTool.TitlePrefix))
}
// Add labels configuration
if len(data.SafeOutputs.MissingTool.Labels) > 0 {
labelsJSON, err := json.Marshal(data.SafeOutputs.MissingTool.Labels)
if err == nil {
missingToolEnvVars = append(missingToolEnvVars, fmt.Sprintf(" GH_AW_MISSING_TOOL_LABELS: %q\n", string(labelsJSON)))
}
}
// Add workflow metadata for consistency
missingToolEnvVars = append(missingToolEnvVars, buildWorkflowMetadataEnvVarsWithTrackerID(data.Name, data.Source, data.TrackerID, buildLocalWorkflowSourceURL(c.markdownPath))...)
// Build the missing_tool processing step (without artifact downloads - already added above)
missingToolSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Record missing tool",
StepID: "missing_tool",
MainJobName: mainJobName,
CustomEnvVars: missingToolEnvVars,
Script: "const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); await main();",
ScriptFile: "missing_tool.cjs",
CustomToken: data.SafeOutputs.MissingTool.GitHubToken,
})
steps = append(steps, missingToolSteps...)
}
// Add report_incomplete processing step if report-incomplete is configured
if data.SafeOutputs.ReportIncomplete != nil {
// Build custom environment variables specific to report-incomplete
var reportIncompleteEnvVars []string
reportIncompleteEnvVars = append(reportIncompleteEnvVars, buildTemplatableIntEnvVar("GH_AW_REPORT_INCOMPLETE_MAX", data.SafeOutputs.ReportIncomplete.Max)...)
// Add create-issue configuration
reportIncompleteEnvVars = append(reportIncompleteEnvVars, buildTemplatableBoolEnvVar("GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE", data.SafeOutputs.ReportIncomplete.CreateIssue)...)
// Add title-prefix configuration
if data.SafeOutputs.ReportIncomplete.TitlePrefix != "" {
reportIncompleteEnvVars = append(reportIncompleteEnvVars, fmt.Sprintf(" GH_AW_REPORT_INCOMPLETE_TITLE_PREFIX: %q\n", data.SafeOutputs.ReportIncomplete.TitlePrefix))
}
// Add labels configuration
if len(data.SafeOutputs.ReportIncomplete.Labels) > 0 {
labelsJSON, err := json.Marshal(data.SafeOutputs.ReportIncomplete.Labels)
if err == nil {
reportIncompleteEnvVars = append(reportIncompleteEnvVars, fmt.Sprintf(" GH_AW_REPORT_INCOMPLETE_LABELS: %q\n", string(labelsJSON)))
}
}
// Add workflow metadata for consistency
reportIncompleteEnvVars = append(reportIncompleteEnvVars, buildWorkflowMetadataEnvVarsWithTrackerID(data.Name, data.Source, data.TrackerID, buildLocalWorkflowSourceURL(c.markdownPath))...)
// Build the report_incomplete processing step (without artifact downloads - already added above)
reportIncompleteSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Record incomplete",
StepID: "report_incomplete",
MainJobName: mainJobName,
CustomEnvVars: reportIncompleteEnvVars,
Script: "const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); await main();",
ScriptFile: "report_incomplete_handler.cjs",
CustomToken: data.SafeOutputs.ReportIncomplete.GitHubToken,
})
steps = append(steps, reportIncompleteSteps...)
}
// Add agent failure handling step - creates/updates an issue when agent job fails
// This step always runs and checks if the agent job failed
// Build environment variables for the agent failure handler
// Serialize messages config once for reuse in both handler steps below.
var messagesJSON string
if data.SafeOutputs != nil && data.SafeOutputs.Messages != nil {
if json, jsonErr := serializeMessagesConfig(data.SafeOutputs.Messages); jsonErr != nil {
notifyCommentLog.Printf("Warning: failed to serialize messages config: %v", jsonErr)
} else {
messagesJSON = json
}
}
var agentFailureEnvVars []string
agentFailureEnvVars = append(agentFailureEnvVars, buildWorkflowMetadataEnvVarsWithTrackerID(data.Name, data.Source, data.TrackerID, buildLocalWorkflowSourceURL(c.markdownPath))...)
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n")
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_AGENT_CONCLUSION: ${{ needs.%s.result }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_WORKFLOW_ID: %q\n", data.WorkflowID))
actionFailureIssueExpiresHours := DefaultActionFailureIssueExpiresHours
repoConfig, repoConfigErr := c.loadRepoConfig()
if repoConfigErr != nil {
notifyCommentLog.Printf(
"Warning: failed to load repo config for action failure issue expiration (using default %d hours): %v. Check that %s exists and matches schema requirements",
DefaultActionFailureIssueExpiresHours,
repoConfigErr,
RepoConfigFileName,
)
} else {
actionFailureIssueExpiresHours = repoConfig.ActionFailureIssueExpiresHours()
}
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: %q\n", strconv.Itoa(actionFailureIssueExpiresHours)))
// Pass the engine ID so the failure handler can surface which AI engine terminated
if data.EngineConfig != nil && data.EngineConfig.ID != "" {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_ENGINE_ID: %q\n", data.EngineConfig.ID))
}
// Only add secret_verification_result if the engine provides a validate-secret step.
// The validate-secret step runs in the activation job, so the output is on needs.activation.
engine, err := c.getAgenticEngine(data.AI)
if err != nil {
return nil, fmt.Errorf("failed to get agentic engine: %w", err)
}
if EngineHasValidateSecretStep(engine, data) {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.%s.outputs.secret_verification_result }}\n", string(constants.ActivationJobName)))
}
// Add checkout_pr_success to detect PR checkout failures (e.g., PR merged and branch deleted)
// Only add if the checkout-pr step will be generated (requires contents read access)
if ShouldGeneratePRCheckoutStep(data) {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.%s.outputs.checkout_pr_success }}\n", mainJobName))
}
// Pass ET usage and AI credits outputs from the agent job so the failure handler
// can report the number of credits used and the configured limit.
// GH_AW_AIC carries the actual credits consumed; GH_AW_MAX_AI_CREDITS carries the
// configured per-run budget (either the literal compile-time value when set via
// max-ai-credits frontmatter, or the runtime vars expression used by the firewall).
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_EFFECTIVE_TOKENS: ${{ needs.%s.outputs.effective_tokens || '' }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.%s.outputs.ai_credits_rate_limit_error || 'false' }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_AIC: ${{ needs.%s.outputs.aic }}\n", mainJobName))
if data != nil && IsDetectionJobEnabled(data.SafeOutputs) {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_THREAT_DETECTION_AIC: ${{ needs.%s.outputs.aic }}\n", constants.DetectionJobName))
}
if data != nil && data.EngineConfig != nil && data.EngineConfig.MaxAICredits != 0 {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_MAX_AI_CREDITS: %q\n", strconv.FormatInt(data.EngineConfig.MaxAICredits, 10)))
} else {
expr := compilerenv.BuildDefaultMaxAICreditsExpression(strconv.FormatInt(constants.DefaultMaxAICredits, 10))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_MAX_AI_CREDITS: %s\n", expr))
}
// Pass engine error-detection outputs to the conclusion job when the selected engine
// provides a host-runner detect-agent-errors step.
// Contract: engines returning a non-empty GetErrorDetectionScriptId() must run
// actions/setup/js/detect_agent_errors.cjs, which emits all four outputs below.
// These outputs cover:
// - inference_access_error: token lacks inference access
// - mcp_policy_error: MCP servers blocked by enterprise/organization policy
// - agentic_engine_timeout: engine process killed by signal (step timeout)
// - model_not_supported_error: configured model name is invalid or unavailable
if engine.GetErrorDetectionScriptId() != "" {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.%s.outputs.inference_access_error }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_MCP_POLICY_ERROR: ${{ needs.%s.outputs.mcp_policy_error }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.%s.outputs.agentic_engine_timeout }}\n", mainJobName))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.%s.outputs.model_not_supported_error }}\n", mainJobName))
}
// Pass the engine's primary AI inference API hosts so the failure handler can detect
// credential authentication rejections in the firewall audit log without relying solely
// on hardcoded patterns. The list is comma-separated (e.g. "api.enterprise.githubcopilot.com,api.githubcopilot.com").
if apiHosts := getEngineAPIHosts(data, engine); len(apiHosts) > 0 {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_ENGINE_API_HOSTS: %q\n", strings.Join(apiHosts, ",")))
}
// Pass assignment error outputs from safe_outputs job if assign-to-agent is configured
if data.SafeOutputs != nil && data.SafeOutputs.AssignToAgent != nil {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_ASSIGNMENT_ERRORS: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_errors }}\n")
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_ASSIGNMENT_ERROR_COUNT: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_error_count }}\n")
}
// Pass create_discussion error outputs from safe_outputs job if create-discussions is configured
if data.SafeOutputs != nil && data.SafeOutputs.CreateDiscussions != nil {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CREATE_DISCUSSION_ERRORS: ${{ needs.safe_outputs.outputs.create_discussion_errors }}\n")
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CREATE_DISCUSSION_ERROR_COUNT: ${{ needs.safe_outputs.outputs.create_discussion_error_count }}\n")
}
// Pass code-push failure outputs from safe_outputs job if push-to-pull-request-branch or create-pull-request is configured
if data.SafeOutputs != nil && (data.SafeOutputs.PushToPullRequestBranch != nil || data.SafeOutputs.CreatePullRequests != nil) {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }}\n")
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }}\n")
}
// Pass GitHub App token minting failure status so the handler can surface auth errors.
// The safe_outputs job tracks whether its token step failed as a job output.
// The conclusion job tracks its own app token step outcome directly via steps context.
if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_SAFE_OUTPUTS_APP_TOKEN_MINTING_FAILED: ${{ needs.safe_outputs.outputs.app_token_minting_failed }}\n")
// Also check the conclusion job's own app token step outcome; this is important because
// the Handle Agent Failure step must use if: always() to run even when this step fails.
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CONCLUSION_APP_TOKEN_MINTING_FAILED: ${{ steps.safe-outputs-app-token.outcome == 'failure' }}\n")
}
// Pass activation job GitHub App token minting failure status if configured.
if data.ActivationGitHubApp != nil {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_ACTIVATION_APP_TOKEN_MINTING_FAILED: ${{ needs.%s.outputs.activation_app_token_minting_failed }}\n", string(constants.ActivationJobName)))
}
// Always pass lockdown check failure status so the handler can surface configuration
// errors even when the agent job was skipped due to the lockdown check failing.
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.%s.outputs.lockdown_check_failed }}\n", string(constants.ActivationJobName)))
// Pass stale lock file check failure status so the handler can surface a specialised
// failure issue / comment with remediation guidance when the frontmatter hash check detects
// that the compiled lock file no longer matches its source markdown file.
// This output is only set when stale-check is enabled (the default); when disabled the
// expression evaluates to "" which handle_agent_failure treats as "not failed".
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.%s.outputs.stale_lock_file_failed }}\n", string(constants.ActivationJobName)))
if hasMaxDailyAICGuardrail(data) {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED: ${{ needs.%s.outputs.daily_effective_workflow_exceeded }}\n", string(constants.ActivationJobName)))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS: ${{ needs.%s.outputs.daily_effective_workflow_total_effective_tokens }}\n", string(constants.ActivationJobName)))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD: ${{ needs.%s.outputs.daily_effective_workflow_threshold }}\n", string(constants.ActivationJobName)))
}
// Pass custom messages config if present (JSON computed once above)
if messagesJSON != "" {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_SAFE_OUTPUT_MESSAGES: %q\n", messagesJSON))
}
// Pass repo-memory failure outputs if repo-memory is configured
// This allows the agent failure handler to report both job-level failures and validation issues
if data.RepoMemoryConfig != nil && len(data.RepoMemoryConfig.Memories) > 0 {
// Pass the overall push_repo_memory job result so the failure handler
// can report when the push job itself fails (e.g. permission or configuration errors)
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_PUSH_REPO_MEMORY_RESULT: ${{ needs.push_repo_memory.result }}\n")
for _, memory := range data.RepoMemoryConfig.Memories {
// Add validation status for each memory
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_REPO_MEMORY_VALIDATION_FAILED_%s: ${{ needs.push_repo_memory.outputs.validation_failed_%s }}\n", memory.ID, memory.ID))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_REPO_MEMORY_VALIDATION_ERROR_%s: ${{ needs.push_repo_memory.outputs.validation_error_%s }}\n", memory.ID, memory.ID))
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_REPO_MEMORY_PATCH_SIZE_EXCEEDED_%s: ${{ needs.push_repo_memory.outputs.patch_size_exceeded_%s }}\n", memory.ID, memory.ID))
}
}
// Pass group-reports configuration flag (defaults to false if not specified)
if data.SafeOutputs != nil && data.SafeOutputs.GroupReports {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_GROUP_REPORTS: \"true\"\n")
} else {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_GROUP_REPORTS: \"false\"\n")
}
// Pass report-failure-as-issue configuration flag (defaults to true if not specified)
if data.SafeOutputs != nil && data.SafeOutputs.ReportFailureAsIssue != nil && !*data.SafeOutputs.ReportFailureAsIssue {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_FAILURE_REPORT_AS_ISSUE: \"false\"\n")
} else {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_FAILURE_REPORT_AS_ISSUE: \"true\"\n")
}
// Pass missing-tool report-as-failure flag (defaults to true when not configured).
// When false, missing_tool signals will not activate failure handling.
if data.SafeOutputs != nil && data.SafeOutputs.MissingTool != nil && data.SafeOutputs.MissingTool.ReportAsFailure != nil {
agentFailureEnvVars = append(agentFailureEnvVars, buildTemplatableBoolEnvVar("GH_AW_MISSING_TOOL_REPORT_AS_FAILURE", data.SafeOutputs.MissingTool.ReportAsFailure)...)
} else {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: \"true\"\n")
}
// Pass missing-data report-as-failure flag (defaults to true when not configured).
// When false, missing_data signals will not activate failure handling.
if data.SafeOutputs != nil && data.SafeOutputs.MissingData != nil && data.SafeOutputs.MissingData.ReportAsFailure != nil {
agentFailureEnvVars = append(agentFailureEnvVars, buildTemplatableBoolEnvVar("GH_AW_MISSING_DATA_REPORT_AS_FAILURE", data.SafeOutputs.MissingData.ReportAsFailure)...)
} else {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_MISSING_DATA_REPORT_AS_FAILURE: \"true\"\n")
}
// Pass failure-issue-repo configuration (optional, defaults to current repo)
if data.SafeOutputs != nil && data.SafeOutputs.FailureIssueRepo != "" {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_FAILURE_ISSUE_REPO: %q\n", data.SafeOutputs.FailureIssueRepo))
}
// Pass timeout minutes value so the failure handler can provide an actionable hint when timed out
timeoutValue := strings.TrimPrefix(data.TimeoutMinutes, "timeout-minutes: ")
if timeoutValue != "" {
agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_TIMEOUT_MINUTES: %q\n", timeoutValue))
}
// Pass cache-memory availability flag so the failure handler can detect cache-miss
// misconfigurations: a cache_miss reported by the agent despite cache-memory being available
// indicates the prompt is referencing an incorrect file path within the cache directory.
if data.CacheMemoryConfig != nil && len(data.CacheMemoryConfig.Caches) > 0 {
agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_CACHE_MEMORY_ENABLED: \"true\"\n")
}
// Build the agent failure handling step.
// Use if: always() so this step runs even when an earlier step in the conclusion job
// (such as the GitHub App token minting step) has failed. The handler uses the default
// GITHUB_TOKEN and does not depend on the app-minted token.
agentFailureSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Handle agent failure",
StepID: "handle_agent_failure",
MainJobName: mainJobName,
CustomEnvVars: agentFailureEnvVars,
Script: "const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); await main();",
ScriptFile: "handle_agent_failure.cjs",
CustomToken: "", // Will use default GITHUB_TOKEN
StepCondition: "always()",
})
steps = append(steps, agentFailureSteps...)
// Build environment variables for the conclusion script
var customEnvVars []string
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_COMMENT_ID: ${{ needs.%s.outputs.comment_id }}\n", constants.ActivationJobName))
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_COMMENT_REPO: ${{ needs.%s.outputs.comment_repo }}\n", constants.ActivationJobName))
customEnvVars = append(customEnvVars, " GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n")
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", data.Name))
// Pass the tracker-id if present
if data.TrackerID != "" {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_TRACKER_ID: %q\n", data.TrackerID))
}
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_CONCLUSION: ${{ needs.%s.result }}\n", mainJobName))
// Pass safe_outputs job result so the conclusion script can detect when safe outputs failed
// to deliver even if the agent succeeded (e.g. a 422 error during PR review submission).
if slices.Contains(safeOutputJobNames, string(constants.SafeOutputsJobName)) {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_SAFE_OUTPUTS_RESULT: ${{ needs.%s.result }}\n", constants.SafeOutputsJobName))
notifyCommentLog.Print("Added safe_outputs job result environment variable to conclusion job")
}
// Pass detection conclusion and reason if threat detection is enabled (in separate detection job)
if IsDetectionJobEnabled(data.SafeOutputs) {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_DETECTION_CONCLUSION: ${{ needs.%s.outputs.detection_conclusion }}\n", constants.DetectionJobName))
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_DETECTION_REASON: ${{ needs.%s.outputs.detection_reason }}\n", constants.DetectionJobName))
notifyCommentLog.Print("Added detection conclusion and reason environment variables to conclusion job")
}
// Pass assignment error count to the conclusion step so the status comment reflects assignment failures
if data.SafeOutputs != nil && data.SafeOutputs.AssignToAgent != nil {
customEnvVars = append(customEnvVars, " GH_AW_ASSIGNMENT_ERROR_COUNT: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_error_count }}\n")
}
// Pass custom messages config if present (JSON computed once above, reused here)
if messagesJSON != "" {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_SAFE_OUTPUT_MESSAGES: %q\n", messagesJSON))
}
// Pass safe output job information for link generation
if len(safeOutputJobNames) > 0 {
safeOutputJobsJSON, jobURLEnvVars := buildSafeOutputJobsEnvVars(safeOutputJobNames)
if safeOutputJobsJSON != "" {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_SAFE_OUTPUT_JOBS: %q\n", safeOutputJobsJSON))
customEnvVars = append(customEnvVars, jobURLEnvVars...)
notifyCommentLog.Printf("Added safe output jobs info for %d job(s)", len(safeOutputJobNames))
}
}
// Get token from config
var token string
if data.SafeOutputs != nil && data.SafeOutputs.AddComments != nil {
token = data.SafeOutputs.AddComments.GitHubToken
}
// Only add the conclusion update step if status comments are explicitly enabled
if data.StatusComment != nil && *data.StatusComment {
// Build the conclusion GitHub Script step (without artifact downloads - already added above)
scriptSteps := c.buildGitHubScriptStepWithoutDownload(data, GitHubScriptStepConfig{
StepName: "Update reaction comment with completion status",
StepID: "conclusion",
MainJobName: mainJobName,
CustomEnvVars: customEnvVars,
Script: getNotifyCommentErrorScript(),
ScriptFile: "notify_comment_error.cjs",
CustomToken: token,
})
steps = append(steps, scriptSteps...)
}
// Note: Unlock step has been moved to a dedicated unlock job
// that always runs, even if this conclusion job doesn't run.
// See buildUnlockJob() in compiler_unlock_job.go
// Append OTLP conclusion span step (no-op when endpoint is not configured).
// Note: this step is now handled by the action post step (post.js) so no
// injected step is needed here.
// Build the condition for this job:
// 1. always() - run even if agent fails
// 2. agent was activated (not skipped) OR lockdown check failed in activation job
// 3. IF comment_id exists: add_comment job either doesn't exist OR hasn't created a comment yet
//
// Note: The job should always run to handle noop messages (either update comment or write to summary)
// The script (notify_comment_error.cjs) handles the case where there's no comment gracefully
alwaysFunc := BuildFunctionCall("always")
// Check that agent job was activated (not skipped)
agentNotSkipped := BuildNotEquals(
BuildPropertyAccess(fmt.Sprintf("needs.%s.result", constants.AgentJobName)),
BuildStringLiteral("skipped"),
)
// Check if the lockdown check failed in the activation job — when this happens the agent
// is skipped, but we still want the conclusion job to run so it can report the failure.
lockdownCheckFailed := BuildEquals(
BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.lockdown_check_failed", string(constants.ActivationJobName))),
BuildStringLiteral("true"),
)
// Check if the frontmatter hash (stale lock file) check failed in the activation job.
// When this happens the agent is skipped, but we still want the conclusion job to run
// so it can surface a specialised failure issue with remediation guidance.
staleLockFileFailed := BuildEquals(
BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.stale_lock_file_failed", string(constants.ActivationJobName))),
BuildStringLiteral("true"),
)
activationGuardrailsFailed := BuildOr(lockdownCheckFailed, staleLockFileFailed)
if hasMaxDailyAICGuardrail(data) {
dailyAICWorkflowExceeded := BuildEquals(
BuildPropertyAccess(fmt.Sprintf("needs.%s.outputs.daily_effective_workflow_exceeded", string(constants.ActivationJobName))),
BuildStringLiteral("true"),
)
activationGuardrailsFailed = BuildOr(activationGuardrailsFailed, dailyAICWorkflowExceeded)
}
// Agent not skipped OR an activation guardrail failed and intentionally skipped the agent.
agentNotSkippedOrActivationFailed := BuildOr(agentNotSkipped, activationGuardrailsFailed)
// Check if add_comment job exists in the safe output jobs
hasAddCommentJob := slices.Contains(safeOutputJobNames, "add_comment")
// Build the condition based on whether add_comment job exists
var condition ConditionNode
if hasAddCommentJob {
// If add_comment job exists, also check that it hasn't already created a comment
// This prevents duplicate updates when add_comment has already updated the activation comment
noAddCommentOutput := &NotNode{
Child: BuildPropertyAccess("needs.add_comment.outputs.comment_id"),
}
condition = BuildAnd(
BuildAnd(alwaysFunc, agentNotSkippedOrActivationFailed),
noAddCommentOutput,
)
} else {
// If add_comment job doesn't exist, just check the basic conditions
condition = BuildAnd(alwaysFunc, agentNotSkippedOrActivationFailed)
}
// Build dependencies - this job depends on all safe output jobs to ensure it runs last
needs := []string{mainJobName, string(constants.ActivationJobName)}
needs = append(needs, safeOutputJobNames...)
// When threat detection is enabled, the conclusion job also depends on the detection job
// so that needs.detection.outputs.detection_conclusion is accessible.
if IsDetectionJobEnabled(data.SafeOutputs) {
needs = append(needs, string(constants.DetectionJobName))
notifyCommentLog.Print("Added detection job dependency to conclusion job")
}
notifyCommentLog.Printf("Job built successfully: dependencies_count=%d", len(needs))
// Create outputs for the job (include noop and missing_tool outputs if configured)
outputs := map[string]string{}
if data.SafeOutputs.NoOp != nil {
outputs["noop_message"] = "${{ steps.noop.outputs.noop_message }}"
}
if data.SafeOutputs.MissingTool != nil {
outputs["tools_reported"] = "${{ steps.missing_tool.outputs.tools_reported }}"
outputs["total_count"] = "${{ steps.missing_tool.outputs.total_count }}"
}
if data.SafeOutputs.ReportIncomplete != nil {
outputs["incomplete_count"] = "${{ steps.report_incomplete.outputs.incomplete_count }}"
}
// Compute permissions based on configured safe outputs (principle of least privilege)
permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs)
// Build concurrency config for the conclusion job using the workflow ID.
// This prevents concurrent agents on the same workflow from interfering with each other.
var concurrency string
if data.WorkflowID != "" {
group := "gh-aw-conclusion-" + data.WorkflowID
// If the user specified a job-discriminator, append it so that concurrent
// runs with different inputs (fan-out pattern) do not share the same group.
if data.ConcurrencyJobDiscriminator != "" {
notifyCommentLog.Printf("Appending job discriminator to conclusion job concurrency group: %s", data.ConcurrencyJobDiscriminator)
group = fmt.Sprintf("%s-%s", group, data.ConcurrencyJobDiscriminator)
}
concurrencyValue := fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", group)
if isGroupConcurrencyQueueEnabled(data) {
concurrencyValue += "\n queue: max"
}
concurrency = c.indentYAMLLines(concurrencyValue, " ")
notifyCommentLog.Printf("Configuring conclusion job concurrency group: %s", group)
}
// In script mode, explicitly add a cleanup step (mirrors post.js in dev/release/action mode).
if c.actionMode.IsScript() {
steps = append(steps, c.generateScriptModeCleanupStep())
}
job := &Job{
Name: "conclusion",
If: RenderCondition(condition),
RunsOn: c.formatFrameworkJobRunsOn(data),
Environment: c.indentYAMLLines(resolveSafeOutputsEnvironment(data), " "),
Permissions: permissions.RenderToYAML(),
Concurrency: concurrency,
Steps: steps,
Needs: needs,
Outputs: outputs,
}
return job, nil
}
// buildUsageArtifactUploadSteps creates steps that collect and upload a compact usage artifact.
// The artifact includes aw-info.jsonl, agent_usage.jsonl, detection_usage.jsonl, and agent/detection token usage JSONL files (when present).
func buildUsageArtifactUploadSteps(prefix string, pinAction func(string) string) []string {
usageArtifactName := prefix + "usage"
return []string{
" - name: Collect usage artifact files\n",
" if: always()\n",
" continue-on-error: true\n",
" run: |\n",
" mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection\n",
" echo \"Usage artifact source file status:\"\n",
fmt.Sprintf(" for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/%s /tmp/gh-aw/%s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do\n", constants.AgentUsageJsonlFilename, constants.DetectionUsageJsonlFilename),
" [ -f \"$file\" ] && echo \"FOUND: $file\" || echo \"MISSING: $file\"\n",
" done\n",
" [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true\n",
fmt.Sprintf(" [ -f /tmp/gh-aw/%[1]s ] && cp /tmp/gh-aw/%[1]s /tmp/gh-aw/usage/%[1]s || true\n", constants.AgentUsageJsonlFilename),
fmt.Sprintf(" [ -f /tmp/gh-aw/%[1]s ] && cp /tmp/gh-aw/%[1]s /tmp/gh-aw/usage/%[1]s || true\n", constants.DetectionUsageJsonlFilename),
" [ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true\n",
" [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl\n",
" [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl\n",
" find /tmp/gh-aw/usage -type f -print | sort\n",
" - name: Upload usage artifact\n",
" if: always()\n",
" continue-on-error: true\n",
fmt.Sprintf(" uses: %s\n", pinAction("actions/upload-artifact")),
" with:\n",
fmt.Sprintf(" name: %s\n", usageArtifactName),
" path: |\n",
" /tmp/gh-aw/usage/aw-info.jsonl\n",
" /tmp/gh-aw/usage/" + constants.AgentUsageJsonlFilename + "\n",
" /tmp/gh-aw/usage/" + constants.DetectionUsageJsonlFilename + "\n",
" /tmp/gh-aw/usage/agent/token_usage.jsonl\n",
" /tmp/gh-aw/usage/detection/token_usage.jsonl\n",
" if-no-files-found: ignore\n",
}
}
// isGroupConcurrencyQueueEnabled reports whether compiler-generated concurrency groups
// should include queue: max. The feature is enabled by default and can be disabled
// with features.group-concurrency-queue: false.
func isGroupConcurrencyQueueEnabled(data *WorkflowData) bool {
flag := strings.ToLower(strings.TrimSpace(string(constants.GroupConcurrencyQueueFeatureFlag)))
if data != nil && data.Features != nil {
for key, value := range data.Features {
if strings.EqualFold(key, flag) {
return parseGroupConcurrencyQueueFeatureValue(value)
}
}
}
return true
}
func parseGroupConcurrencyQueueFeatureValue(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
switch normalized {
case "false", "0", "off", "no":
return false
default:
return true
}
default:
return true
}
}
// systemSafeOutputJobNames contains job names that are built-in system jobs and should not be
// treated as custom safe output job types in the GH_AW_SAFE_OUTPUT_JOBS mapping.
// The safe output handler manager uses this mapping to determine which message types are
// handled by custom job steps (and therefore should be silently skipped rather than flagged
// as "no handler loaded").
var systemSafeOutputJobNames = map[string]bool{
"safe_outputs": true, // consolidated safe outputs job
"upload_assets": true, // upload assets job
}
// buildSafeOutputJobsEnvVars creates environment variables for safe output job URLs
// Returns both a JSON mapping and the actual environment variable declarations.
// The mapping includes:
// - Built-in jobs with known URL outputs (e.g., create_issue → issue_url)
// - Custom safe-output jobs (from safe-outputs.jobs) with an empty URL key, so the handler
// manager knows those message types are handled by a dedicated job step and should be
// skipped gracefully rather than reported as "No handler loaded".
func buildSafeOutputJobsEnvVars(jobNames []string) (string, []string) {
// Map job names to their expected URL output keys
jobOutputMapping := make(map[string]string)
var envVars []string
for _, jobName := range jobNames {
var urlKey string
switch jobName {
case "create_issue":
urlKey = "issue_url"
case "add_comment":
urlKey = "comment_url"
case "create_pull_request":
urlKey = "pull_request_url"
case "create_discussion":
urlKey = "discussion_url"
case "create_pr_review_comment":
urlKey = "review_comment_url"
case "close_issue":
urlKey = "issue_url"
case "close_pull_request":
urlKey = "pull_request_url"
case "close_discussion":
urlKey = "discussion_url"
case "create_agent_session":
urlKey = "task_url"
case "push_to_pull_request_branch":
urlKey = "commit_url"
default:
if !systemSafeOutputJobNames[jobName] {
// Custom safe-output job: include in the mapping with an empty URL key so the
// handler manager can silently skip messages of this type.
jobOutputMapping[jobName] = ""
}
continue
}
jobOutputMapping[jobName] = urlKey
// Add environment variable for this job's URL output
envVarName := fmt.Sprintf("GH_AW_OUTPUT_%s_%s",
toEnvVarCase(jobName),
toEnvVarCase(urlKey))
envVars = append(envVars,
fmt.Sprintf(" %s: ${{ needs.%s.outputs.%s }}\n",
envVarName, jobName, urlKey))
}
if len(jobOutputMapping) == 0 {
return "", nil
}
jsonBytes, err := json.Marshal(jobOutputMapping)
if err != nil {
notifyCommentLog.Printf("Warning: failed to marshal safe output jobs info: %v", err)
return "", nil
}
return string(jsonBytes), envVars
}
// toEnvVarCase converts a string to uppercase environment variable case
func toEnvVarCase(s string) string {
// Convert to uppercase and keep underscores
var result strings.Builder
for _, ch := range s {
if ch >= 'a' && ch <= 'z' {
result.WriteRune(ch - 32) // Convert to uppercase
} else if ch >= 'A' && ch <= 'Z' {
result.WriteRune(ch)
} else if ch == '_' {
result.WriteString("_")
}
}
return result.String()
}
// getEngineAPIHosts returns the primary AI inference API hostnames for the given engine and
// workflow data. These are the hosts that appear in the firewall audit log when the engine
// makes authenticated API calls. The returned slice is used to populate GH_AW_ENGINE_API_HOSTS
// so the failure handler can detect credential authentication rejections without relying solely
// on hardcoded host patterns.
//
// Resolution order (per engine):
// - engine.api-target (explicit GHES / enterprise override) takes precedence
// - Default public API hostname(s) for the engine
func getEngineAPIHosts(data *WorkflowData, engine CodingAgentEngine) []string {
if engine == nil {
return nil
}
// Explicit api-target overrides the engine-specific default for all engine types.
if data != nil && data.EngineConfig != nil && data.EngineConfig.APITarget != "" {
return []string{data.EngineConfig.APITarget}
}
switch engine.(type) {
case *CopilotEngine:
// Return the full set of known Copilot inference endpoints so that any variant
// (enterprise, business, individual, or the routing hub) is covered.
return []string{
"api.enterprise.githubcopilot.com",
"api.githubcopilot.com",
"api.business.githubcopilot.com",
"api.individual.githubcopilot.com",
}
case *ClaudeEngine:
return []string{"api.anthropic.com"}
case *CodexEngine:
return []string{"api.openai.com"}
case *GeminiEngine:
return []string{DefaultGeminiAPITarget}
case *AntigravityEngine:
return []string{DefaultAntigravityAPITarget}
default:
// Custom or unknown engine — no known API hosts without explicit api-target.
return nil
}
}