Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions pkg/cli/logs_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,23 @@ type downloadArtifactsOptions struct {
// usage artifact. In this mode, workflow-run log downloads are intentionally skipped
// to minimize API and transfer volume for lightweight reporting paths.
func isUsageOnlyArtifactFilter(artifactFilter []string) bool {
return len(artifactFilter) == 1 && artifactFilter[0] == constants.UsageArtifactName.String()
if len(artifactFilter) != 1 {
return false
}
for _, artifact := range artifactFilter {
return artifact == constants.UsageArtifactName.String()
}
return false
}

func isInfoOnlyArtifactFilter(artifactFilter []string) bool {
return len(artifactFilter) == 1 && artifactFilter[0] == constants.InfoArtifactName.String()
if len(artifactFilter) != 1 {
return false
}
for _, artifact := range artifactFilter {
return artifact == constants.InfoArtifactName.String()
}
return false
}

// isInfoWithOptionalUsageArtifactFilter reports whether the artifact filter requests
Expand Down Expand Up @@ -478,12 +490,8 @@ func downloadWorkflowRunLogsForDiagnostics(ctx context.Context, opts downloadArt
// repo/hostname overrides for cross-repo and multi-host support.
func buildBulkDownloadArgs(opts downloadArtifactsOptions) []string {
ghArgs := []string{"run", "download", strconv.FormatInt(opts.runID, 10), "--dir", opts.outputDir}
if opts.owner != "" && opts.repo != "" {
if opts.hostname != "" && opts.hostname != "github.com" {
ghArgs = append(ghArgs, "-R", filepath.Join(opts.hostname, opts.owner, opts.repo))
} else {
ghArgs = append(ghArgs, "-R", filepath.Join(opts.owner, opts.repo))
}
if repoFlag := buildRepoFlag(opts.owner, opts.repo, opts.hostname); repoFlag != "" {
ghArgs = append(ghArgs, "-R", repoFlag)
}
return ghArgs
}
Expand Down
37 changes: 37 additions & 0 deletions pkg/cli/logs_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ import (
"github.com/stretchr/testify/require"
)

func TestLogsBuildBulkDownloadArgs(t *testing.T) {
t.Parallel()

tests := []struct {
name string
owner string
repo string
hostname string
repoFlag string
}{
{name: "default host", owner: "octo", repo: "repo", repoFlag: "octo/repo"},
{name: "github.com", owner: "octo", repo: "repo", hostname: "github.com", repoFlag: "octo/repo"},
{name: "enterprise host", owner: "octo", repo: "repo", hostname: "ghe.example.com", repoFlag: "ghe.example.com/octo/repo"},
{name: "implicit repository"},
{name: "missing owner", repo: "repo", hostname: "ghe.example.com"},
{name: "missing repo", owner: "octo", hostname: "ghe.example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
outputDir := filepath.Join("logs", "run-42")
args := buildBulkDownloadArgs(downloadArtifactsOptions{
runID: 42,
outputDir: outputDir,
owner: tt.owner,
repo: tt.repo,
hostname: tt.hostname,
})
want := []string{"run", "download", "42", "--dir", outputDir}
if tt.repoFlag != "" {
want = append(want, "-R", tt.repoFlag)
}
assert.Equal(t, want, args)
})
}
}

func TestDownloadWorkflowLogs(t *testing.T) {
t.Skip("Skipping slow network-dependent test")

Expand Down
29 changes: 22 additions & 7 deletions pkg/cli/logs_github_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -281,7 +282,7 @@ func applyNonZero[T comparable](target *T, value T) bool {
func fetchAndCacheWorkflowRunMetadata(ctx context.Context, currentRun WorkflowRun, outputDir, owner, repo, hostname string, verbose bool) (WorkflowRun, error) {
responsePath := filepath.Join(outputDir, runAPIResponseFileName)
if output, err := os.ReadFile(responsePath); err == nil {
if run, parseErr := parseWorkflowRunAPIResponse(output); parseErr == nil && cachedWorkflowRunMetadataIsCurrent(run, currentRun, owner, repo) {
if run, parseErr := parseWorkflowRunAPIResponse(output); parseErr == nil && cachedWorkflowRunMetadataIsCurrent(run, currentRun, owner, repo, hostname) {
return run, nil
}
logsGitHubAPILog.Printf("Ignoring invalid cached workflow run API response: path=%s", responsePath)
Expand All @@ -307,7 +308,7 @@ func fetchAndCacheWorkflowRunMetadata(ctx context.Context, currentRun WorkflowRu
return run, nil
}

func workflowRunMetadataCacheNeedsRefresh(outputDir string, currentRun WorkflowRun, owner, repo string) (bool, error) {
func workflowRunMetadataCacheNeedsRefresh(outputDir string, currentRun WorkflowRun, owner, repo, hostname string) (bool, error) {
output, err := os.ReadFile(filepath.Join(outputDir, runAPIResponseFileName))
if errors.Is(err, os.ErrNotExist) {
return true, nil
Expand All @@ -316,14 +317,20 @@ func workflowRunMetadataCacheNeedsRefresh(outputDir string, currentRun WorkflowR
return false, fmt.Errorf("failed to read cached workflow run API response: %w", err)
}
cached, err := parseWorkflowRunAPIResponse(output)
return err != nil || !cachedWorkflowRunMetadataIsCurrent(cached, currentRun, owner, repo), nil
return err != nil || !cachedWorkflowRunMetadataIsCurrent(cached, currentRun, owner, repo, hostname), nil
}

func cachedWorkflowRunMetadataIsCurrent(cached, current WorkflowRun, owner, repo string) bool {
func cachedWorkflowRunMetadataIsCurrent(cached, current WorkflowRun, owner, repo, hostname string) bool {
if cached.DatabaseID != current.DatabaseID {
return false
}
if owner != "" && repo != "" && !strings.EqualFold(cached.Repository, filepath.Join(owner, repo)) {
if owner != "" && repo != "" && !strings.EqualFold(cached.Repository, buildRepoFlag(owner, repo, "")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cache validation still ignores hostname, so a run cached from one GitHub host can be reused for the same owner/repo on another host and silently attach the wrong workflow metadata.

💡 Why this blocks the fix

buildBulkDownloadArgs now correctly distinguishes ghe.example.com/octo/repo from octo/repo, but the metadata cache is still keyed by run-<id> and these helpers only compare owner/repo. In a multi-host setup, that means a cached API response from GHES can satisfy a later refresh for GitHub.com (or vice versa) when the run IDs overlap.

Please thread hostname through workflowRunMetadataCacheNeedsRefresh / cachedWorkflowRunMetadataIsCurrent and persist enough host context in the cached metadata (or isolate the cache path per host) so cross-host cache hits fail closed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f520155f: cache freshness now compares the cached run URL’s hostname with the requested host (or the current run’s URL host), and rejects entries when host context is unavailable. Added GitHub.com/GHES cross-host regression cases.

return false
}
if hostname == "" {
hostname = workflowRunURLHostname(current.URL)
}
if hostname == "" || !strings.EqualFold(workflowRunURLHostname(cached.URL), hostname) {
return false
}
if current.Attempt > 0 && cached.Attempt != current.Attempt {
Expand All @@ -335,6 +342,14 @@ func cachedWorkflowRunMetadataIsCurrent(cached, current WorkflowRun, owner, repo
return cached.Status == "completed"
}

func workflowRunURLHostname(runURL string) string {
parsed, err := url.Parse(runURL)
if err != nil {
return ""
}
return parsed.Hostname()
}

// fetchJobDetails gets detailed job information including durations for a workflow run.
// Errors from the underlying API call are suppressed so that callers can continue
// processing even when job data is unavailable (e.g. missing permissions).
Expand Down Expand Up @@ -524,8 +539,8 @@ func listWorkflowRunsWithPagination(opts ListWorkflowRunsOptions) ([]WorkflowRun
totalFetched := len(runs)
if opts.OldestFetchedCreatedAt != nil {
var oldest time.Time
if totalFetched > 0 {
oldest = runs[totalFetched-1].CreatedAt
for _, run := range runs {
oldest = run.CreatedAt
}
*opts.OldestFetchedCreatedAt = oldest
}
Expand Down
53 changes: 49 additions & 4 deletions pkg/cli/logs_github_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,51 @@ func TestWorkflowRunUnmarshal(t *testing.T) {
assert.Equal(t, 2, runs[0].Attempt, "Attempt should be populated")
}

func TestLogsCachedWorkflowRunMetadataIsCurrent(t *testing.T) {
t.Parallel()

updatedAt := time.Date(2026, time.September, 1, 10, 2, 0, 0, time.UTC)
cached := WorkflowRun{
DatabaseID: 42,
URL: "https://github.com/octo/repo/actions/runs/42",
Repository: "octo/repo",
Attempt: 3,
Status: "completed",
UpdatedAt: updatedAt,
}
tests := []struct {
name string
current WorkflowRun
owner string
repo string
hostname string
cachedURL string
want bool
}{
{name: "matching repository", current: cached, owner: "octo", repo: "repo", want: true},
{name: "case insensitive repository", current: cached, owner: "Octo", repo: "Repo", want: true},
{name: "implicit repository", current: cached, want: true},
{name: "matching enterprise host", current: WorkflowRun{DatabaseID: 42, URL: "https://ghe.example.com/octo/repo/actions/runs/42"}, owner: "octo", repo: "repo", hostname: "GHE.EXAMPLE.COM", cachedURL: "https://ghe.example.com/octo/repo/actions/runs/42", want: true},
{name: "different requested host", current: cached, owner: "octo", repo: "repo", hostname: "ghe.example.com"},
{name: "different inferred host", current: WorkflowRun{DatabaseID: 42, URL: "https://ghe.example.com/octo/repo/actions/runs/42"}, owner: "octo", repo: "repo"},
{name: "missing host context", current: WorkflowRun{DatabaseID: 42}, owner: "octo", repo: "repo"},
{name: "different repository", current: cached, owner: "other", repo: "repo"},
{name: "different run", current: WorkflowRun{DatabaseID: 43}, owner: "octo", repo: "repo"},
{name: "different attempt", current: WorkflowRun{DatabaseID: 42, Attempt: 4}, owner: "octo", repo: "repo"},
{name: "newer update", current: WorkflowRun{DatabaseID: 42, UpdatedAt: updatedAt.Add(time.Minute)}, owner: "octo", repo: "repo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
testCached := cached
if tt.cachedURL != "" {
testCached.URL = tt.cachedURL
}
assert.Equal(t, tt.want, cachedWorkflowRunMetadataIsCurrent(testCached, tt.current, tt.owner, tt.repo, tt.hostname))
})
}
}

func TestFilterIgnoredWorkflowRuns(t *testing.T) {
runs := []WorkflowRun{{DatabaseID: 100}, {DatabaseID: 200}, {DatabaseID: 300}}

Expand Down Expand Up @@ -149,7 +194,7 @@ func TestFetchAndCacheWorkflowRunMetadata(t *testing.T) {
require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755))
t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))

currentRun := WorkflowRun{DatabaseID: 42, Attempt: 3, Status: "completed"}
currentRun := WorkflowRun{DatabaseID: 42, URL: "https://github.com/octo/repo/actions/runs/42", Attempt: 3, Status: "completed"}
run, err := fetchAndCacheWorkflowRunMetadata(context.Background(), currentRun, outputDir, "octo", "repo", "", false)
require.NoError(t, err)
assert.Equal(t, int64(42), run.DatabaseID)
Expand All @@ -172,20 +217,20 @@ func TestFetchAndCacheWorkflowRunMetadata(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "api repos/octo/repo/actions/runs/42\n", string(argsLog))

needsRefresh, err := workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "other-repo")
needsRefresh, err := workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "other-repo", "")
require.NoError(t, err)
assert.True(t, needsRefresh, "a cache entry from another repository must be refreshed")

needsRefresh, err = workflowRunMetadataCacheNeedsRefresh(outputDir, WorkflowRun{
DatabaseID: 42,
Attempt: 4,
Status: "completed",
}, "octo", "repo")
}, "octo", "repo", "")
require.NoError(t, err)
assert.True(t, needsRefresh, "a cache entry from another attempt must be refreshed")

require.NoError(t, os.WriteFile(cachePath, []byte(strings.Replace(string(cached), `"status":"completed"`, `"status":"in_progress"`, 1)), 0o600))
needsRefresh, err = workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "repo")
needsRefresh, err = workflowRunMetadataCacheNeedsRefresh(outputDir, currentRun, "octo", "repo", "")
require.NoError(t, err)
assert.True(t, needsRefresh, "a mutable cache entry must be refreshed")

Expand Down
9 changes: 5 additions & 4 deletions pkg/cli/logs_orchestrator_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -977,15 +977,15 @@ func TestDownloadWorkflowLogsReportsCollectionStatsForJSONLAndDiskCacheHits(t *t
require.NoError(t, os.MkdirAll(diskRunDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(diskRunDir, runAPIResponseFileName),
fmt.Appendf(nil, `{"id":%d,"status":"completed","conclusion":"success","path":".github/workflows/disk-cached.lock.yml"}`, diskCachedRunID),
fmt.Appendf(nil, `{"id":%d,"html_url":"https://github.com/owner/repo/actions/runs/%d","status":"completed","conclusion":"success","path":".github/workflows/disk-cached.lock.yml","repository":{"full_name":"owner/repo"}}`, diskCachedRunID, diskCachedRunID),
0o600,
))
require.NoError(t, saveRunSummary(diskRunDir, &RunSummary{
CLIVersion: GetVersion(),
RunID: diskCachedRunID,
ProcessedAt: time.Now(),
RunAnalysis: RunAnalysis{
Run: WorkflowRun{DatabaseID: diskCachedRunID, WorkflowName: "Disk Cached", WorkflowPath: ".github/workflows/disk-cached.lock.yml", Status: "completed", Conclusion: "success"},
Run: WorkflowRun{DatabaseID: diskCachedRunID, URL: "https://github.com/owner/repo/actions/runs/101", WorkflowName: "Disk Cached", WorkflowPath: ".github/workflows/disk-cached.lock.yml", Status: "completed", Conclusion: "success"},
},
}, false))
require.NoError(t, markArtifactDownloaded(diskRunDir, constants.UsageArtifactName.String()))
Expand All @@ -995,7 +995,7 @@ func TestDownloadWorkflowLogsReportsCollectionStatsForJSONLAndDiskCacheHits(t *t
jsonlUpdatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
cachedJSONLPath := filepath.Join(outputDir, "cached-logs.jsonl")
cachedRecord := fmt.Sprintf(
`{"schema_version":%d,"kind":"run","run":{"run_id":%d,"workflow_path":".github/workflows/jsonl-cached.lock.yml","status":"completed","conclusion":"success","run_attempt":"1","updated_at":%q,"repository":"owner/repo"}}`+"\n",
`{"schema_version":%d,"kind":"run","run":{"run_id":%d,"url":"https://github.com/owner/repo/actions/runs/202","workflow_path":".github/workflows/jsonl-cached.lock.yml","status":"completed","conclusion":"success","run_attempt":"1","updated_at":%q,"repository":"owner/repo"}}`+"\n",
cachedLogsJSONLSchemaVersion, jsonlCachedRunID, jsonlUpdatedAt.Format(time.RFC3339),
)
require.NoError(t, os.WriteFile(cachedJSONLPath, []byte(cachedRecord), 0o600))
Expand All @@ -1008,9 +1008,10 @@ func TestDownloadWorkflowLogsReportsCollectionStatsForJSONLAndDiskCacheHits(t *t
}
return workflowRunBatch{
runs: []WorkflowRun{
{DatabaseID: diskCachedRunID},
{DatabaseID: diskCachedRunID, URL: "https://github.com/owner/repo/actions/runs/101"},
{
DatabaseID: jsonlCachedRunID,
URL: "https://github.com/owner/repo/actions/runs/202",
Status: "completed",
Conclusion: "success",
Attempt: 1,
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/logs_run_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ type cachedRunMetadataRefresh struct {
}

func refreshCachedRunMetadata(ctx context.Context, cachedRun *WorkflowRun, runOutputDir string, run WorkflowRun, params concurrentRunDownloadParams) cachedRunMetadataRefresh {
needsRefresh, err := workflowRunMetadataCacheNeedsRefresh(runOutputDir, run, params.dlOwner, params.dlRepo)
needsRefresh, err := workflowRunMetadataCacheNeedsRefresh(runOutputDir, run, params.dlOwner, params.dlRepo, params.dlHost)
if err == nil && needsRefresh {
err = waitForConfiguredRateLimit(ctx, params.verbose, params.maxGitHubAPIRateLimit, 1, params.rateLimitState)
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/logs_run_processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,12 +474,13 @@ func TestTryLoadCachedRunResultDoesNotPersistRejectedCurrentPathAfterMetadataRef
require.NoError(t, markArtifactDownloaded(runOutputDir, string(ArtifactSetAll)))
require.NoError(t, os.WriteFile(
filepath.Join(runOutputDir, runAPIResponseFileName),
[]byte(`{"id":132,"status":"completed","conclusion":"success","name":"Refreshed Agentic","path":".github/workflows/refreshed.lock.yml"}`),
[]byte(`{"id":132,"html_url":"https://github.com/owner/repo/actions/runs/132","status":"completed","conclusion":"success","name":"Refreshed Agentic","path":".github/workflows/refreshed.lock.yml"}`),
0o600,
))

result, ok := tryLoadCachedRunResult(context.Background(), WorkflowRun{
DatabaseID: 132,
URL: "https://github.com/owner/repo/actions/runs/132",
WorkflowPath: ".github/workflows/ci.yml",
}, runOutputDir, concurrentRunDownloadParams{})

Expand Down
Loading