Skip to content

Commit c8ee6a4

Browse files
authored
fix: propagate worker job permissions to call-workflow caller jobs (#21061) (#21080)
1 parent 76f6c6e commit c8ee6a4

5 files changed

Lines changed: 831 additions & 6 deletions

File tree

.github/workflows/smoke-call-workflow.lock.yml

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package workflow
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/github/gh-aw/pkg/logger"
9+
"github.com/github/gh-aw/pkg/parser"
10+
"github.com/goccy/go-yaml"
11+
)
12+
13+
var callWorkflowPermissionsLog = logger.New("workflow:call_workflow_permissions")
14+
15+
// extractJobPermissionsFromParsedWorkflow extracts and merges all job-level permissions
16+
// from a parsed GitHub Actions workflow map. Returns the union of all jobs' permissions.
17+
func extractJobPermissionsFromParsedWorkflow(workflow map[string]any) *Permissions {
18+
merged := NewPermissions()
19+
20+
jobsSection, ok := workflow["jobs"]
21+
if !ok {
22+
return merged
23+
}
24+
25+
jobsMap, ok := jobsSection.(map[string]any)
26+
if !ok {
27+
return merged
28+
}
29+
30+
for jobName, jobConfig := range jobsMap {
31+
jobMap, ok := jobConfig.(map[string]any)
32+
if !ok {
33+
continue
34+
}
35+
36+
permsValue, hasPerms := jobMap["permissions"]
37+
if !hasPerms {
38+
callWorkflowPermissionsLog.Printf("Job '%s' has no permissions block, skipping", jobName)
39+
continue
40+
}
41+
42+
jobPerms := NewPermissionsParserFromValue(permsValue).ToPermissions()
43+
callWorkflowPermissionsLog.Printf("Merging permissions from job '%s'", jobName)
44+
merged.Merge(jobPerms)
45+
}
46+
47+
return merged
48+
}
49+
50+
// extractCallWorkflowPermissions returns the permission superset required by the worker
51+
// workflow identified by workflowName. It resolves the file in priority order:
52+
// .lock.yml > .yml > .md (same-batch compilation target).
53+
//
54+
// For compiled files (.lock.yml / .yml), permissions are extracted from each job's
55+
// permissions block and unioned together. For .md sources, the frontmatter-level
56+
// permissions field is used as a proxy (the compiler will turn it into per-job
57+
// permissions when the worker is eventually compiled).
58+
//
59+
// Returns nil when no workflow file is found or no permissions are declared.
60+
// The caller should omit the permissions block on the call-* job in that case.
61+
func extractCallWorkflowPermissions(workflowName, markdownPath string) (*Permissions, error) {
62+
fileResult, err := findWorkflowFile(workflowName, markdownPath)
63+
if err != nil {
64+
return nil, fmt.Errorf("failed to find workflow file for '%s': %w", workflowName, err)
65+
}
66+
67+
// Priority: .lock.yml > .yml > .md
68+
if fileResult.lockExists {
69+
return extractPermissionsFromYAMLFile(fileResult.lockPath)
70+
}
71+
72+
if fileResult.ymlExists {
73+
return extractPermissionsFromYAMLFile(fileResult.ymlPath)
74+
}
75+
76+
if fileResult.mdExists {
77+
return extractPermissionsFromMDFile(fileResult.mdPath)
78+
}
79+
80+
// No file found — return nil so the caller omits the permissions block.
81+
callWorkflowPermissionsLog.Printf("No workflow file found for '%s', skipping permissions", workflowName)
82+
return nil, nil
83+
}
84+
85+
// extractPermissionsFromYAMLFile reads a .lock.yml or .yml workflow file, parses it,
86+
// and returns the merged permissions from all its jobs.
87+
func extractPermissionsFromYAMLFile(filePath string) (*Permissions, error) {
88+
cleanPath := filepath.Clean(filePath)
89+
// filePath originates from findWorkflowFile(), which validates all paths via
90+
// isPathWithinDir() to prevent directory traversal before returning them.
91+
content, err := os.ReadFile(cleanPath) // #nosec G304 -- path pre-validated by findWorkflowFile() via isPathWithinDir()
92+
if err != nil {
93+
return nil, fmt.Errorf("failed to read workflow file %s: %w", filePath, err)
94+
}
95+
96+
var workflow map[string]any
97+
if err := yaml.Unmarshal(content, &workflow); err != nil {
98+
return nil, fmt.Errorf("failed to parse workflow file %s: %w", filePath, err)
99+
}
100+
101+
perms := extractJobPermissionsFromParsedWorkflow(workflow)
102+
callWorkflowPermissionsLog.Printf("Extracted permissions from YAML file %s", filePath)
103+
return perms, nil
104+
}
105+
106+
// extractPermissionsFromMDFile reads a .md workflow source and uses the frontmatter-level
107+
// permissions field as a proxy for the job permissions that will be generated when the
108+
// worker is compiled.
109+
func extractPermissionsFromMDFile(mdPath string) (*Permissions, error) {
110+
// mdPath originates from findWorkflowFile(), which validates paths via
111+
// isPathWithinDir() to prevent directory traversal before returning them.
112+
content, err := os.ReadFile(mdPath) // #nosec G304 -- path pre-validated by findWorkflowFile() via isPathWithinDir()
113+
if err != nil {
114+
return nil, fmt.Errorf("failed to read workflow source %s: %w", mdPath, err)
115+
}
116+
117+
result, err := parser.ExtractFrontmatterFromContent(string(content))
118+
if err != nil || result == nil {
119+
callWorkflowPermissionsLog.Printf("Failed to extract frontmatter from %s: %v", mdPath, err)
120+
return nil, nil
121+
}
122+
123+
permsValue, hasPerms := result.Frontmatter["permissions"]
124+
if !hasPerms {
125+
callWorkflowPermissionsLog.Printf("No permissions in frontmatter of %s", mdPath)
126+
return nil, nil
127+
}
128+
129+
perms := NewPermissionsParserFromValue(permsValue).ToPermissions()
130+
callWorkflowPermissionsLog.Printf("Extracted permissions from .md source %s", mdPath)
131+
return perms, nil
132+
}

0 commit comments

Comments
 (0)