Skip to content

Commit 640b266

Browse files
authored
Cache complete workflow run metadata in logs (#59257)
1 parent 25fb43e commit 640b266

13 files changed

Lines changed: 406 additions & 21 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# ADR-59257: Cache complete workflow run metadata in logs
2+
3+
**Date**: 2026-09-07
4+
**Status**: Draft
5+
**Deciders**: gh-aw maintainers
6+
7+
---
8+
9+
### Context
10+
11+
`gh aw logs` previously omitted workflow-run metadata that downstream consumers had to retrieve separately with `gh api`. This PR updates the logs processing pipeline under `pkg/cli/` to fetch the full GitHub Actions run response, persist it as `run.json` beside each run's cached artifacts, and reuse that cache when possible. The diff also changes report-building logic so repository, actor, event, SHA, workflow path, and run attempt data come from GitHub metadata first and are overridden only by non-empty `aw_info.json` values. The repository needs an explicit decision for whether workflow run metadata should be treated as a first-class cached input of the logs subsystem rather than as an optional post-processing lookup.
12+
13+
### Decision
14+
15+
We will fetch the complete GitHub Actions workflow-run API response during `gh aw logs` processing, cache it per run as `run.json`, and use it as the baseline source for run metadata in logs reports. We will preserve and refresh that cache for both newly downloaded and previously cached runs, and we will exclude `run.json` from artifact inventories while retaining it during storage pruning. Non-empty `aw_info.json` fields will remain authoritative overrides so workflow-emitted metadata can correct or augment GitHub-provided values without discarding the GitHub baseline.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Keep metadata uncached and require downstream consumers to call `gh api`
20+
21+
This matches the previous behavior where logs output omitted some workflow-run metadata and consumers had to fetch it separately. It was considered because it keeps the logs pipeline simpler and avoids storing another API response on disk. It was not chosen because the PR description and diff both show repeated downstream need for repository, actor, event, attempt, commit, and workflow-path data, making separate follow-up API calls redundant and harder to reuse.
22+
23+
#### Alternative 2: Cache only a reduced summary instead of the full workflow-run API response
24+
25+
The project could translate the GitHub response directly into `run_summary.json` fields and avoid storing raw API output. This was considered because it would reduce stored data volume and keep the cache schema narrower. It was not chosen because the diff explicitly adds `run.json`, validates malformed or mismatched cached responses, and uses the raw response as a reusable source of truth that can backfill older summaries when metadata fields are later needed.
26+
27+
#### Alternative 3: Treat `aw_info.json` as the sole source of metadata
28+
29+
Another option would be to continue relying on workflow-emitted metadata files for repository, actor, SHA, event, and attempt data. This was considered because `aw_info.json` is already part of the logs ecosystem and can contain workflow-specific context. It was not chosen because `aw_info.json` may be absent or partially populated, and the diff intentionally changes precedence so GitHub API metadata provides a reliable baseline while `aw_info.json` overrides only non-empty fields.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Downstream logs consumers can read complete run metadata from cached output without making separate GitHub API calls.
35+
- Newly downloaded runs and previously cached runs share the same metadata source and healing behavior, improving consistency of `run_summary.json` and report output.
36+
- The logs report gains richer repository, organization, actor, attempt, SHA, and event fields even when `aw_info.json` is missing or incomplete.
37+
38+
#### Negative
39+
- Each processed run now requires an additional GitHub Actions API request and an extra cached file, increasing API usage and local storage.
40+
- The logs subsystem must maintain parsing, cache validation, and merge logic for raw workflow-run responses in addition to existing summary and jobs caches.
41+
- Metadata precedence is more complex because the system must combine GitHub baseline fields with selective `aw_info.json` overrides.
42+
43+
#### Neutral
44+
- `run.json` becomes a preserved internal cache artifact that is intentionally excluded from artifact listings shown to users.
45+
- Existing cached runs may be rewritten as metadata is backfilled or healed, even when no new workflow artifacts are downloaded.
46+
- Tests now need to cover both cache reuse behavior and metadata precedence between GitHub responses and `aw_info.json`.
47+
48+
---
49+
50+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/logs_download.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,11 @@ func downloadArtifactsIndividually(ctx context.Context, opts downloadArtifactsOp
385385
if err := downloadArtifactsByName(ctx, opts, downloadableNames); err != nil {
386386
return err
387387
}
388-
if fileutil.IsDirEmpty(opts.outputDir) {
388+
artifacts, err := listArtifacts(opts.outputDir)
389+
if err != nil {
390+
return err
391+
}
392+
if len(artifacts) == 0 {
389393
// Downloads were attempted but none succeeded; treat as no artifacts.
390394
return ErrNoArtifacts
391395
}
@@ -429,9 +433,9 @@ func buildBulkDownloadArgs(opts downloadArtifactsOptions) []string {
429433
ghArgs := []string{"run", "download", strconv.FormatInt(opts.runID, 10), "--dir", opts.outputDir}
430434
if opts.owner != "" && opts.repo != "" {
431435
if opts.hostname != "" && opts.hostname != "github.com" {
432-
ghArgs = append(ghArgs, "-R", opts.hostname+"/"+opts.owner+"/"+opts.repo)
436+
ghArgs = append(ghArgs, "-R", filepath.Join(opts.hostname, opts.owner, opts.repo))
433437
} else {
434-
ghArgs = append(ghArgs, "-R", opts.owner+"/"+opts.repo)
438+
ghArgs = append(ghArgs, "-R", filepath.Join(opts.owner, opts.repo))
435439
}
436440
}
437441
return ghArgs
@@ -493,7 +497,11 @@ func recoverBulkDownloadArtifacts(ctx context.Context, opts downloadArtifactsOpt
493497
}
494498
}
495499

496-
if skippedNonZipArtifacts && fileutil.IsDirEmpty(opts.outputDir) {
500+
artifacts, err := listArtifacts(opts.outputDir)
501+
if err != nil {
502+
return err
503+
}
504+
if skippedNonZipArtifacts && len(artifacts) == 0 {
497505
// All artifacts were non-zip (none could be extracted) so nothing was downloaded.
498506
// Treat this the same as a run with no artifacts — the audit will rely solely on
499507
// workflow logs rather than artifact content.

pkg/cli/logs_download_artifacts.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ func listArtifacts(outputDir string) ([]string, error) {
4242
return err
4343
}
4444

45-
// Skip directories and synthesized cache/summary files
46-
if info.IsDir() || filepath.Base(path) == runSummaryFileName || filepath.Base(path) == jobsAPIResponseFileName {
45+
if info.IsDir() {
4746
return nil
4847
}
4948

@@ -52,6 +51,10 @@ func listArtifacts(outputDir string) ([]string, error) {
5251
if err != nil {
5352
return err
5453
}
54+
// Skip only root-level synthesized cache/summary files.
55+
if relPath == runSummaryFileName || relPath == jobsAPIResponseFileName || relPath == runAPIResponseFileName {
56+
return nil
57+
}
5558

5659
artifacts = append(artifacts, relPath)
5760
return nil

pkg/cli/logs_github_api.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,146 @@ func writeSensitiveFile(path string, data []byte) (err error) {
195195
return nil
196196
}
197197

198+
type workflowRunAPIResponse struct {
199+
ID int64 `json:"id"`
200+
RunNumber int `json:"run_number"`
201+
RunAttempt int `json:"run_attempt"`
202+
HTMLURL string `json:"html_url"`
203+
Status string `json:"status"`
204+
Conclusion string `json:"conclusion"`
205+
Name string `json:"name"`
206+
Path string `json:"path"`
207+
CreatedAt time.Time `json:"created_at"`
208+
RunStartedAt time.Time `json:"run_started_at"`
209+
UpdatedAt time.Time `json:"updated_at"`
210+
Event string `json:"event"`
211+
HeadBranch string `json:"head_branch"`
212+
HeadSHA string `json:"head_sha"`
213+
DisplayTitle string `json:"display_title"`
214+
Actor struct {
215+
Login string `json:"login"`
216+
} `json:"actor"`
217+
Repository struct {
218+
FullName string `json:"full_name"`
219+
} `json:"repository"`
220+
}
221+
222+
func parseWorkflowRunAPIResponse(data []byte) (WorkflowRun, error) {
223+
var response workflowRunAPIResponse
224+
if err := json.Unmarshal(data, &response); err != nil {
225+
return WorkflowRun{}, fmt.Errorf("failed to parse workflow run API response: %w", err)
226+
}
227+
return WorkflowRun{
228+
DatabaseID: response.ID,
229+
Number: response.RunNumber,
230+
URL: response.HTMLURL,
231+
Status: response.Status,
232+
Conclusion: response.Conclusion,
233+
WorkflowName: response.Name,
234+
WorkflowPath: response.Path,
235+
CreatedAt: response.CreatedAt,
236+
StartedAt: response.RunStartedAt,
237+
UpdatedAt: response.UpdatedAt,
238+
Event: response.Event,
239+
HeadBranch: response.HeadBranch,
240+
HeadSha: response.HeadSHA,
241+
DisplayTitle: response.DisplayTitle,
242+
Attempt: response.RunAttempt,
243+
Repository: response.Repository.FullName,
244+
Actor: response.Actor.Login,
245+
}, nil
246+
}
247+
248+
func applyWorkflowRunMetadata(run *WorkflowRun, metadata WorkflowRun) bool {
249+
applied := applyNonZero(&run.DatabaseID, metadata.DatabaseID)
250+
applied = applyNonZero(&run.Number, metadata.Number) || applied
251+
applied = applyNonZero(&run.URL, metadata.URL) || applied
252+
applied = applyNonZero(&run.Status, metadata.Status) || applied
253+
applied = applyNonZero(&run.Conclusion, metadata.Conclusion) || applied
254+
if !strings.HasPrefix(metadata.WorkflowName, constants.GithubDir) || run.WorkflowName == "" {
255+
applied = applyNonZero(&run.WorkflowName, metadata.WorkflowName) || applied
256+
}
257+
applied = applyNonZero(&run.WorkflowPath, metadata.WorkflowPath) || applied
258+
applied = applyNonZero(&run.CreatedAt, metadata.CreatedAt) || applied
259+
applied = applyNonZero(&run.StartedAt, metadata.StartedAt) || applied
260+
applied = applyNonZero(&run.UpdatedAt, metadata.UpdatedAt) || applied
261+
applied = applyNonZero(&run.Event, metadata.Event) || applied
262+
applied = applyNonZero(&run.HeadBranch, metadata.HeadBranch) || applied
263+
applied = applyNonZero(&run.HeadSha, metadata.HeadSha) || applied
264+
applied = applyNonZero(&run.DisplayTitle, metadata.DisplayTitle) || applied
265+
applied = applyNonZero(&run.Attempt, metadata.Attempt) || applied
266+
applied = applyNonZero(&run.Repository, metadata.Repository) || applied
267+
applied = applyNonZero(&run.Actor, metadata.Actor) || applied
268+
return applied
269+
}
270+
271+
func applyNonZero[T comparable](target *T, value T) bool {
272+
var zero T
273+
if value == zero {
274+
return false
275+
}
276+
changed := *target != value
277+
*target = value
278+
return changed
279+
}
280+
281+
func fetchAndCacheWorkflowRunMetadata(ctx context.Context, currentRun WorkflowRun, outputDir, owner, repo, hostname string, verbose bool) (WorkflowRun, error) {
282+
responsePath := filepath.Join(outputDir, runAPIResponseFileName)
283+
if output, err := os.ReadFile(responsePath); err == nil {
284+
if run, parseErr := parseWorkflowRunAPIResponse(output); parseErr == nil && cachedWorkflowRunMetadataIsCurrent(run, currentRun, owner, repo) {
285+
return run, nil
286+
}
287+
logsGitHubAPILog.Printf("Ignoring invalid cached workflow run API response: path=%s", responsePath)
288+
} else if !errors.Is(err, os.ErrNotExist) {
289+
return WorkflowRun{}, fmt.Errorf("failed to read cached workflow run API response: %w", err)
290+
}
291+
292+
args := buildWorkflowRunAPIArgs(currentRun.DatabaseID, owner, repo, hostname)
293+
if verbose {
294+
fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Executing: gh "+strings.Join(args, " ")))
295+
}
296+
output, err := workflow.RunGHCombinedContext(ctx, "Fetching run metadata...", args...)
297+
if err != nil {
298+
return WorkflowRun{}, classifyWorkflowRunMetadataError(currentRun.DatabaseID, err, output)
299+
}
300+
run, err := parseWorkflowRunAPIResponse(output)
301+
if err != nil {
302+
return WorkflowRun{}, err
303+
}
304+
if err := writeSensitiveFile(responsePath, output); err != nil {
305+
return WorkflowRun{}, fmt.Errorf("failed to cache workflow run API response: %w", err)
306+
}
307+
return run, nil
308+
}
309+
310+
func workflowRunMetadataCacheNeedsRefresh(outputDir string, currentRun WorkflowRun, owner, repo string) (bool, error) {
311+
output, err := os.ReadFile(filepath.Join(outputDir, runAPIResponseFileName))
312+
if errors.Is(err, os.ErrNotExist) {
313+
return true, nil
314+
}
315+
if err != nil {
316+
return false, fmt.Errorf("failed to read cached workflow run API response: %w", err)
317+
}
318+
cached, err := parseWorkflowRunAPIResponse(output)
319+
return err != nil || !cachedWorkflowRunMetadataIsCurrent(cached, currentRun, owner, repo), nil
320+
}
321+
322+
func cachedWorkflowRunMetadataIsCurrent(cached, current WorkflowRun, owner, repo string) bool {
323+
if cached.DatabaseID != current.DatabaseID {
324+
return false
325+
}
326+
if owner != "" && repo != "" && !strings.EqualFold(cached.Repository, filepath.Join(owner, repo)) {
327+
return false
328+
}
329+
if current.Attempt > 0 && cached.Attempt != current.Attempt {
330+
return false
331+
}
332+
if !current.UpdatedAt.IsZero() && cached.UpdatedAt.Before(current.UpdatedAt) {
333+
return false
334+
}
335+
return cached.Status == "completed"
336+
}
337+
198338
// fetchJobDetails gets detailed job information including durations for a workflow run.
199339
// Errors from the underlying API call are suppressed so that callers can continue
200340
// processing even when job data is unavailable (e.g. missing permissions).

pkg/cli/logs_github_api_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,65 @@ func TestWorkflowRunUnmarshal(t *testing.T) {
4242
assert.Empty(t, runs[0].WorkflowPath, "WorkflowPath should be empty when 'path' field is absent")
4343
}
4444

45+
func TestFetchAndCacheWorkflowRunMetadata(t *testing.T) {
46+
fakeBinDir := testutil.TempDir(t, "fake-gh-*")
47+
outputDir := t.TempDir()
48+
fakeGH := filepath.Join(fakeBinDir, "gh")
49+
argsLogPath := filepath.Join(fakeBinDir, "gh-args.log")
50+
fakeGHScript := "#!/bin/sh\n" +
51+
"printf '%s\\n' \"$*\" >> \"" + argsLogPath + "\"\n" +
52+
"cat <<'EOF'\n" +
53+
`{"id":42,"run_number":7,"run_attempt":3,"html_url":"https://github.com/octo/repo/actions/runs/42","status":"completed","conclusion":"success","name":"Daily report","path":".github/workflows/daily-report.lock.yml","created_at":"2026-09-01T10:00:00Z","run_started_at":"2026-09-01T10:00:01Z","updated_at":"2026-09-01T10:02:00Z","event":"schedule","head_branch":"main","head_sha":"abc123","display_title":"Daily report","actor":{"login":"octocat"},"repository":{"full_name":"octo/repo"}}` + "\n" +
54+
"EOF\n"
55+
require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755))
56+
t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
57+
58+
currentRun := WorkflowRun{DatabaseID: 42, Attempt: 3, Status: "completed"}
59+
run, err := fetchAndCacheWorkflowRunMetadata(context.Background(), currentRun, outputDir, "octo", "repo", "", false)
60+
require.NoError(t, err)
61+
assert.Equal(t, int64(42), run.DatabaseID)
62+
assert.Equal(t, 3, run.Attempt)
63+
assert.Equal(t, ".github/workflows/daily-report.lock.yml", run.WorkflowPath)
64+
assert.Equal(t, "octo/repo", run.Repository)
65+
assert.Equal(t, "octocat", run.Actor)
66+
67+
cachePath := filepath.Join(outputDir, runAPIResponseFileName)
68+
cached, err := os.ReadFile(cachePath)
69+
require.NoError(t, err)
70+
assert.Contains(t, string(cached), `"run_attempt":3`)
71+
72+
require.NoError(t, os.Remove(fakeGH))
73+
cachedRun, err := fetchAndCacheWorkflowRunMetadata(context.Background(), currentRun, outputDir, "octo", "repo", "", false)
74+
require.NoError(t, err, "the second read should use the cache without invoking gh")
75+
assert.Equal(t, run, cachedRun)
76+
77+
argsLog, err := os.ReadFile(argsLogPath)
78+
require.NoError(t, err)
79+
assert.Equal(t, "api repos/octo/repo/actions/runs/42\n", string(argsLog))
80+
81+
needsRefresh, err := workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "other-repo")
82+
require.NoError(t, err)
83+
assert.True(t, needsRefresh, "a cache entry from another repository must be refreshed")
84+
85+
needsRefresh, err = workflowRunMetadataCacheNeedsRefresh(outputDir, WorkflowRun{
86+
DatabaseID: 42,
87+
Attempt: 4,
88+
Status: "completed",
89+
}, "octo", "repo")
90+
require.NoError(t, err)
91+
assert.True(t, needsRefresh, "a cache entry from another attempt must be refreshed")
92+
93+
require.NoError(t, os.WriteFile(cachePath, []byte(strings.Replace(string(cached), `"status":"completed"`, `"status":"in_progress"`, 1)), 0o600))
94+
needsRefresh, err = workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "repo")
95+
require.NoError(t, err)
96+
assert.True(t, needsRefresh, "a mutable cache entry must be refreshed")
97+
98+
metadataApplied := applyWorkflowRunMetadata(&WorkflowRun{WorkflowName: "Daily report"}, WorkflowRun{
99+
WorkflowName: ".github/workflows/daily-report.lock.yml",
100+
})
101+
assert.False(t, metadataApplied, "a path-like API name must not replace a resolved display name")
102+
}
103+
45104
// TestBuildCreatedFilter verifies that buildCreatedFilter always produces a single
46105
// --created expression that enforces all supplied date bounds. The key invariant is that
47106
// StartDate is never silently dropped, which was the root cause of the bug where runs

pkg/cli/logs_json_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"time"
1313

1414
"github.com/github/gh-aw/pkg/testutil"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
1517
)
1618

1719
// TestBuildLogsData tests the structured data creation for logs
@@ -994,6 +996,52 @@ func TestBuildLogsDataOrganizationEmptyWhenNoRepository(t *testing.T) {
994996
}
995997
}
996998

999+
func TestBuildLogsDataUsesGitHubRunMetadataWithoutAwInfo(t *testing.T) {
1000+
t.Parallel()
1001+
runDir := t.TempDir()
1002+
logsData := buildLogsData([]ProcessedRun{{
1003+
Run: WorkflowRun{
1004+
DatabaseID: 9003,
1005+
WorkflowName: "daily-report",
1006+
WorkflowPath: ".github/workflows/daily-report.lock.yml",
1007+
Status: "completed",
1008+
Conclusion: "failure",
1009+
Repository: "myorg/myrepo",
1010+
Actor: "octocat",
1011+
Attempt: 2,
1012+
HeadSha: "abc123",
1013+
Event: "schedule",
1014+
LogsPath: runDir,
1015+
},
1016+
}}, runDir, nil)
1017+
1018+
require.Len(t, logsData.Runs, 1)
1019+
run := logsData.Runs[0]
1020+
assert.Equal(t, "myorg/myrepo", run.Repository)
1021+
assert.Equal(t, "myorg", run.Organization)
1022+
assert.Equal(t, "octocat", run.Actor)
1023+
assert.Equal(t, "2", run.RunAttempt)
1024+
assert.Equal(t, "abc123", run.SHA)
1025+
assert.Equal(t, "schedule", run.EventName)
1026+
}
1027+
1028+
func TestApplyGitHubMetadataToRunDataPreservesExistingValues(t *testing.T) {
1029+
t.Parallel()
1030+
runData := RunData{
1031+
Repository: "myorg/myrepo",
1032+
SHA: "abc123",
1033+
Actor: "octocat",
1034+
EventName: "schedule",
1035+
}
1036+
1037+
applyGitHubMetadataToRunData(&runData, WorkflowRun{})
1038+
1039+
assert.Equal(t, "myorg/myrepo", runData.Repository)
1040+
assert.Equal(t, "abc123", runData.SHA)
1041+
assert.Equal(t, "octocat", runData.Actor)
1042+
assert.Equal(t, "schedule", runData.EventName)
1043+
}
1044+
9971045
// TestInferWorkflowPathFromDisplayName verifies that display names are correctly
9981046
// slugified into conventional lock-file paths.
9991047
func TestInferWorkflowPathFromDisplayName(t *testing.T) {

0 commit comments

Comments
 (0)