Skip to content

Commit 70bd59b

Browse files
authored
Cache GitHub Actions job metadata in logs output (#59039)
1 parent 6c75fb6 commit 70bd59b

8 files changed

Lines changed: 270 additions & 107 deletions

pkg/cli/audit_analysis_fanout.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ func collectAuditAnalysisResults(ctx context.Context, run WorkflowRun, runOutput
2020
if includeFirewallAnalyses {
2121
launchFirewallAuditAnalyses(g, gctx, &results, runOutputDir, verbose)
2222
}
23+
if err := g.Wait(); err != nil {
24+
return results, err
25+
}
26+
if ctx.Err() != nil {
27+
return results, ctx.Err()
28+
}
29+
g, gctx = errgroup.WithContext(ctx)
2330
launchSupplementalAuditAnalyses(g, gctx, &results, runOutputDir, verbose)
2431
if err := g.Wait(); err != nil {
2532
return results, err
@@ -44,7 +51,7 @@ func launchCoreAuditAnalyses(g *errgroup.Group, gctx context.Context, results *a
4451
expName, expVariant, _ := firstExperimentAssignment(extractExperimentData(runOutputDir))
4552

4653
launchMetricsAnalysis(g, gctx, results, runOutputDir, verbose, run.WorkflowPath)
47-
launchJobDetailsAnalysis(g, gctx, results, run.DatabaseID, verbose)
54+
launchJobDetailsAnalysis(g, gctx, results, run.DatabaseID, runOutputDir, verbose)
4855
runAuditAnalysis(g, gctx, verbose, "extractMissingToolsFromRun", "Failed to extract missing tools", func(v []MissingToolReport) {
4956
results.missingTools = v
5057
}, func() ([]MissingToolReport, error) {
@@ -100,12 +107,12 @@ func launchMetricsAnalysis(g *errgroup.Group, gctx context.Context, results *aud
100107
}
101108

102109
// launchJobDetailsAnalysis exclusively writes results.jobDetails and results.failedJobCount.
103-
func launchJobDetailsAnalysis(g *errgroup.Group, gctx context.Context, results *auditAnalysisResults, runID int64, verbose bool) {
110+
func launchJobDetailsAnalysis(g *errgroup.Group, gctx context.Context, results *auditAnalysisResults, runID int64, runOutputDir string, verbose bool) {
104111
g.Go(func() error {
105112
if err := gctx.Err(); err != nil {
106113
return err
107114
}
108-
jobDetails, failedJobCount, err := fetchJobDetailsWithCounts(gctx, runID, verbose)
115+
jobDetails, failedJobCount, err := fetchJobDetailsWithCounts(gctx, runID, runOutputDir, verbose)
109116
if err != nil {
110117
if gctx.Err() != nil {
111118
return gctx.Err()

pkg/cli/logs_download_artifacts.go

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"fmt"
1111
"os"
12+
"path"
1213
"path/filepath"
1314
"strconv"
1415
"strings"
@@ -27,9 +28,9 @@ func buildRepoFlag(owner, repo, hostname string) string {
2728
return ""
2829
}
2930
if hostname != "" && hostname != "github.com" {
30-
return hostname + "/" + owner + "/" + repo
31+
return path.Join(hostname, owner, repo)
3132
}
32-
return owner + "/" + repo
33+
return path.Join(owner, repo)
3334
}
3435

3536
// listArtifacts creates a list of all artifact files in the output directory
@@ -41,8 +42,8 @@ func listArtifacts(outputDir string) ([]string, error) {
4142
return err
4243
}
4344

44-
// Skip directories and the summary file itself
45-
if info.IsDir() || filepath.Base(path) == runSummaryFileName {
45+
// Skip directories and synthesized cache/summary files
46+
if info.IsDir() || filepath.Base(path) == runSummaryFileName || filepath.Base(path) == jobsAPIResponseFileName {
4647
return nil
4748
}
4849

@@ -214,54 +215,58 @@ func retryCriticalArtifacts(ctx context.Context, opts downloadArtifactsOptions)
214215
logsDownloadLog.Printf("Critical artifact %q already present, skipping retry", name)
215216
continue
216217
}
218+
retryCriticalArtifact(ctx, opts, repoFlag, name, artifactDir)
219+
}
220+
}
217221

218-
// Stage next to the output directory so promotion can use an atomic same-filesystem rename.
219-
stagingDir, err := os.MkdirTemp(filepath.Dir(opts.outputDir), "."+filepath.Base(opts.outputDir)+"-"+name+"-")
220-
if err != nil {
221-
logsDownloadLog.Printf("Failed to create staging directory for critical artifact %q: %v", name, err)
222-
continue
223-
}
222+
func retryCriticalArtifact(ctx context.Context, opts downloadArtifactsOptions, repoFlag, name, artifactDir string) {
223+
// Stage next to the output directory so promotion can use an atomic same-filesystem rename.
224+
stagingDir, err := os.MkdirTemp(filepath.Dir(opts.outputDir), "."+filepath.Base(opts.outputDir)+"-"+name+"-")
225+
if err != nil {
226+
logsDownloadLog.Printf("Failed to create staging directory for critical artifact %q: %v", name, err)
227+
return
228+
}
224229

225-
retryArgs := []string{"run", "download", strconv.FormatInt(opts.runID, 10), "--name", name, "--dir", stagingDir}
226-
if repoFlag != "" {
227-
retryArgs = append(retryArgs, "-R", repoFlag)
228-
}
230+
retryArgs := []string{"run", "download", strconv.FormatInt(opts.runID, 10), "--name", name, "--dir", stagingDir}
231+
if repoFlag != "" {
232+
retryArgs = append(retryArgs, "-R", repoFlag)
233+
}
234+
235+
logsDownloadLog.Printf("Retrying individual download for artifact %q: gh %s", name, strings.Join(retryArgs, " "))
236+
if opts.verbose {
237+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Retrying download for missing artifact: "+name))
238+
}
229239

230-
logsDownloadLog.Printf("Retrying individual download for artifact %q: gh %s", name, strings.Join(retryArgs, " "))
240+
retryCmd := workflow.ExecGHContext(ctx, retryArgs...)
241+
retryOutput, retryErr := retryCmd.CombinedOutput()
242+
if retryErr != nil {
243+
_ = os.RemoveAll(stagingDir)
244+
logsDownloadLog.Printf("Failed to download artifact %q individually: %v (%s)", name, retryErr, string(retryOutput))
231245
if opts.verbose {
232-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Retrying download for missing artifact: "+name))
246+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not download artifact %q: %v", name, retryErr)))
233247
}
248+
return
249+
}
234250

235-
retryCmd := workflow.ExecGHContext(ctx, retryArgs...)
236-
retryOutput, retryErr := retryCmd.CombinedOutput()
237-
if retryErr != nil {
238-
_ = os.RemoveAll(stagingDir)
239-
logsDownloadLog.Printf("Failed to download artifact %q individually: %v (%s)", name, retryErr, string(retryOutput))
240-
if opts.verbose {
241-
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not download artifact %q: %v", name, retryErr)))
242-
}
243-
} else {
244-
logsDownloadLog.Printf("Successfully downloaded artifact %q individually", name)
245-
if err := os.RemoveAll(artifactDir); err != nil {
246-
_ = os.RemoveAll(stagingDir)
247-
logsDownloadLog.Printf("Failed to remove existing critical artifact directory %q: %v", artifactDir, err)
248-
continue
249-
}
250-
if err := os.Rename(stagingDir, artifactDir); err != nil {
251-
_ = os.RemoveAll(stagingDir)
252-
logsDownloadLog.Printf("Failed to promote critical artifact %q from staging: %v", name, err)
253-
continue
254-
}
255-
// Marker write failures are non-fatal in the retry path: retryCriticalArtifacts
256-
// is a best-effort recovery after a partial bulk download, so a missing marker
257-
// only causes a redundant re-download on the next run (not data loss).
258-
if err := markArtifactDownloaded(opts.outputDir, name); err != nil {
259-
logsDownloadLog.Printf("Failed to mark artifact %q as downloaded: %v", name, err)
260-
}
261-
if opts.verbose {
262-
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Downloaded missing artifact: "+name))
263-
}
264-
}
251+
logsDownloadLog.Printf("Successfully downloaded artifact %q individually", name)
252+
if err := os.RemoveAll(artifactDir); err != nil {
253+
_ = os.RemoveAll(stagingDir)
254+
logsDownloadLog.Printf("Failed to remove existing critical artifact directory %q: %v", artifactDir, err)
255+
return
256+
}
257+
if err := os.Rename(stagingDir, artifactDir); err != nil {
258+
_ = os.RemoveAll(stagingDir)
259+
logsDownloadLog.Printf("Failed to promote critical artifact %q from staging: %v", name, err)
260+
return
261+
}
262+
// Marker write failures are non-fatal in the retry path: retryCriticalArtifacts
263+
// is a best-effort recovery after a partial bulk download, so a missing marker
264+
// only causes a redundant re-download on the next run (not data loss).
265+
if err := markArtifactDownloaded(opts.outputDir, name); err != nil {
266+
logsDownloadLog.Printf("Failed to mark artifact %q as downloaded: %v", name, err)
267+
}
268+
if opts.verbose {
269+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Downloaded missing artifact: "+name))
265270
}
266271
}
267272

pkg/cli/logs_github_api.go

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import (
1616
"fmt"
1717
"os"
1818
"os/exec"
19+
"path/filepath"
1920
"slices"
2021
"strconv"
2122
"strings"
2223
"time"
2324

2425
"github.com/github/gh-aw/pkg/console"
26+
"github.com/github/gh-aw/pkg/constants"
2527
"github.com/github/gh-aw/pkg/logger"
2628
"github.com/github/gh-aw/pkg/workflow"
2729
)
@@ -76,63 +78,121 @@ func buildCreatedFilter(startDate, endDate, beforeDate string) string {
7678
// call and returns the full detail slice together with the count of failed jobs.
7779
// It is the single source of truth for the jobs endpoint; fetchJobDetails and
7880
// fetchJobStatuses are thin wrappers that each return only the value they need.
79-
func fetchJobDetailsWithCounts(ctx context.Context, runID int64, verbose bool) ([]JobInfoWithDuration, int, error) {
81+
func fetchJobDetailsWithCounts(ctx context.Context, runID int64, outputDir string, verbose bool) ([]JobInfoWithDuration, int, error) {
8082
logsGitHubAPILog.Printf("Fetching job details: runID=%d", runID)
8183
if verbose {
8284
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching job details for run %d", runID)))
8385
}
8486

8587
output, err := workflow.RunGHCombinedContext(ctx, "Fetching job details...", "api",
86-
fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID),
87-
"--jq", ".jobs[] | {name: .name, status: .status, conclusion: (.conclusion // \"\"), started_at: .started_at, completed_at: .completed_at, steps: ((.steps // []) | map({name: .name, status: .status, conclusion: (.conclusion // \"\")}))}")
88+
fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs?per_page=100", runID),
89+
"--paginate", "--slurp")
8890
if err != nil {
8991
if verbose {
9092
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", runID, err)))
9193
}
9294
return nil, 0, err
9395
}
9496

95-
var jobs []JobInfoWithDuration
96-
failedJobs := 0
97-
lines := strings.SplitSeq(strings.TrimSpace(string(output)), "\n")
98-
for line := range lines {
99-
if strings.TrimSpace(line) == "" {
100-
continue
101-
}
97+
var responses []struct {
98+
Jobs []json.RawMessage `json:"jobs"`
99+
}
100+
if err := json.Unmarshal(output, &responses); err != nil {
101+
return nil, 0, fmt.Errorf("failed to parse jobs API response: %w", err)
102+
}
102103

103-
var job JobInfo
104-
if err := json.Unmarshal([]byte(line), &job); err != nil {
105-
if verbose {
106-
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Failed to parse job info: "+line))
104+
jobs := []JobInfoWithDuration{}
105+
failedJobs := 0
106+
for _, response := range responses {
107+
for _, rawJob := range response.Jobs {
108+
var job JobInfo
109+
if err := json.Unmarshal(rawJob, &job); err != nil {
110+
logsGitHubAPILog.Printf("Skipping malformed job in run %d: %v", runID, err)
111+
continue
112+
}
113+
jobWithDuration := JobInfoWithDuration{JobInfo: job}
114+
if !job.StartedAt.IsZero() && !job.CompletedAt.IsZero() {
115+
jobWithDuration.Duration = job.CompletedAt.Sub(job.StartedAt)
116+
}
117+
jobs = append(jobs, jobWithDuration)
118+
119+
if isFailureConclusion(job.Conclusion) {
120+
failedJobs++
121+
logsGitHubAPILog.Printf("Found failed job: name=%s, conclusion=%s", job.Name, job.Conclusion)
122+
if verbose {
123+
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Found failed job '%s' with conclusion '%s'", job.Name, job.Conclusion)))
124+
}
107125
}
108-
continue
109-
}
110-
111-
jobWithDuration := JobInfoWithDuration{JobInfo: job}
112-
if !job.StartedAt.IsZero() && !job.CompletedAt.IsZero() {
113-
jobWithDuration.Duration = job.CompletedAt.Sub(job.StartedAt)
114126
}
115-
jobs = append(jobs, jobWithDuration)
127+
}
116128

117-
if isFailureConclusion(job.Conclusion) {
118-
failedJobs++
119-
logsGitHubAPILog.Printf("Found failed job: name=%s, conclusion=%s", job.Name, job.Conclusion)
120-
if verbose {
121-
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Found failed job '%s' with conclusion '%s'", job.Name, job.Conclusion)))
122-
}
129+
if outputDir != "" {
130+
responsePath := filepath.Join(outputDir, jobsAPIResponseFileName)
131+
if err := writeSensitiveFile(responsePath, output); err != nil {
132+
return jobs, failedJobs, &jobDetailsCacheError{err: fmt.Errorf("failed to cache jobs API response: %w", err)}
123133
}
134+
logsGitHubAPILog.Printf("Cached jobs API response: path=%s", responsePath)
124135
}
125136

126137
logsGitHubAPILog.Printf("Job fetch complete: total=%d failed=%d", len(jobs), failedJobs)
127138
return jobs, failedJobs, nil
128139
}
129140

141+
type jobDetailsCacheError struct {
142+
err error
143+
}
144+
145+
func (e *jobDetailsCacheError) Error() string {
146+
return e.err.Error()
147+
}
148+
149+
func (e *jobDetailsCacheError) Unwrap() error {
150+
return e.err
151+
}
152+
153+
func writeSensitiveFile(path string, data []byte) (err error) {
154+
file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
155+
if err != nil {
156+
return err
157+
}
158+
tempPath := file.Name()
159+
closed := false
160+
defer func() {
161+
if !closed {
162+
if closeErr := file.Close(); err == nil {
163+
err = closeErr
164+
}
165+
}
166+
if removeErr := os.Remove(tempPath); err == nil && removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
167+
err = removeErr
168+
}
169+
}()
170+
if err := file.Chmod(constants.FilePermSensitive); err != nil {
171+
return err
172+
}
173+
if _, err := file.Write(data); err != nil {
174+
return err
175+
}
176+
if err := file.Close(); err != nil {
177+
return err
178+
}
179+
closed = true
180+
if err := os.Rename(tempPath, path); err != nil {
181+
return err
182+
}
183+
return nil
184+
}
185+
130186
// fetchJobDetails gets detailed job information including durations for a workflow run.
131187
// Errors from the underlying API call are suppressed so that callers can continue
132188
// processing even when job data is unavailable (e.g. missing permissions).
133-
func fetchJobDetails(ctx context.Context, runID int64, verbose bool) ([]JobInfoWithDuration, error) {
134-
jobs, _, err := fetchJobDetailsWithCounts(ctx, runID, verbose)
189+
func fetchJobDetails(ctx context.Context, runID int64, outputDir string, verbose bool) ([]JobInfoWithDuration, error) {
190+
jobs, _, err := fetchJobDetailsWithCounts(ctx, runID, outputDir, verbose)
135191
if err != nil {
192+
var cacheErr *jobDetailsCacheError
193+
if errors.As(err, &cacheErr) {
194+
return jobs, err
195+
}
136196
// Don't fail the entire operation if we can't get job info
137197
return nil, nil
138198
}
@@ -143,7 +203,7 @@ func fetchJobDetails(ctx context.Context, runID int64, verbose bool) ([]JobInfoW
143203
// Errors from the underlying API call are suppressed so that callers can continue
144204
// processing even when job data is unavailable (e.g. missing permissions).
145205
func fetchJobStatuses(ctx context.Context, runID int64, verbose bool) (int, error) {
146-
_, failedJobs, err := fetchJobDetailsWithCounts(ctx, runID, verbose)
206+
_, failedJobs, err := fetchJobDetailsWithCounts(ctx, runID, "", verbose)
147207
if err != nil {
148208
// Don't fail the entire operation if we can't get job info
149209
return 0, nil
@@ -189,7 +249,7 @@ type ListWorkflowRunsOptions struct {
189249
// not the total number of matching runs the user wants to find.
190250
//
191251
// The processedCount and targetCount parameters are used to display progress in the spinner message.
192-
func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun, int, error) {
252+
func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun, int, error) { //nolint:largefunc // Existing run listing keeps pagination, error classification, and filtering together.
193253
logsGitHubAPILog.Printf("Listing workflow runs: workflow=%s, limit=%d, startDate=%s, endDate=%s, ref=%s", opts.WorkflowName, opts.Limit, opts.StartDate, opts.EndDate, opts.Ref)
194254
args := []string{"run", "list", "--json", "databaseId,number,url,status,conclusion,workflowName,createdAt,startedAt,updatedAt,event,headBranch,headSha,displayTitle"}
195255

0 commit comments

Comments
 (0)