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
2 changes: 1 addition & 1 deletion pkg/workflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ This appendix is generated from the current non-test Go source files in this pac
| `repo_config.go` | `MaintenanceConfig` | `type MaintenanceConfig struct { // RunsOn is the runner label or labels used for all jobs in agentics-maintenance.yml. RunsOn RunsOnValue `json:"runs_on,omitempty"` // ActionFailureIssueExpires configures expiration (in hours) for action // failure issues opened by the conclusion job. Defaults to 168 (7 days). ActionFailureIssueExpires int `json:"action_failure_issue_expires,omitempty"` // LabelTriggers controls all label-triggered jobs (disable_agentic_workflow, // label_apply_safe_outputs, etc.). // The value is treated as an opt-in flag: only true enables the jobs. // nil (omitted) or false both disable label-triggered jobs. // To opt in, set label_triggers: true in aw.json. LabelTriggers *bool `json:"label_triggers,omitempty"` // DisabledJobs lists maintenance job IDs that should be omitted from generated // agentics-maintenance workflows. DisabledJobs []string `json:"disabled_jobs,omitempty"` // Compile controls compile-workflows maintenance job behavior. Compile *MaintenanceCompileConfig `json:"compile,omitempty"` }` | Exported type declared in `repo_config.go`. |
| `repository_features_validation_wasm.go` | `RepositoryFeatures` | `type RepositoryFeatures struct { HasDiscussions bool HasIssues bool }` | Exported type declared in `repository_features_validation_wasm.go`. |
| `runtime_definitions.go` | `RuntimeRequirement` | `type RuntimeRequirement struct { Runtime *Runtime Version string // Empty string means use default ExtraFields map[string]any // Additional 'with' fields from user's setup step (e.g., cache settings) GoModFile string // Path to go.mod file for Go runtime (Go-specific) IfCondition string // Optional GitHub Actions if condition Cooldown bool // If false, disables default dependency cooldown behavior for installs associated with this runtime }` | RuntimeRequirement represents a detected runtime requirement |
| `safe_jobs.go` | `SafeJobConfig` | `type SafeJobConfig struct { // Standard GitHub Actions job properties Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` RunsOn any `yaml:"runs-on,omitempty"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Steps []any `yaml:"steps,omitempty"` Env map[string]string `yaml:"env,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty"` // Additional safe-job specific properties Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` GitHubToken string `yaml:"github-token,omitempty"` Output string `yaml:"output,omitempty"` Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1) }` | SafeJobConfig defines a safe job configuration with GitHub Actions job properties |
| `safe_jobs.go` | `SafeJobConfig` | `type SafeJobConfig struct { // Standard GitHub Actions job properties Name string `yaml:"name,omitempty"` Description string `yaml:"description,omitempty"` RunsOn RunsOnValue `yaml:"runs-on,omitempty"` If string `yaml:"if,omitempty"` Needs []string `yaml:"needs,omitempty"` Steps []any `yaml:"steps,omitempty"` Env map[string]string `yaml:"env,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty"` // Additional safe-job specific properties Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` GitHubToken string `yaml:"github-token,omitempty"` Output string `yaml:"output,omitempty"` Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1) }` | SafeJobConfig defines a safe job configuration with GitHub Actions job properties |
| `safe_outputs_actions.go` | `SafeOutputActionConfig` | `type SafeOutputActionConfig struct { Uses string `yaml:"uses"` Description string `yaml:"description,omitempty"` // optional override of the action's description Env map[string]string `yaml:"env,omitempty"` // additional environment variables for the injected step // Computed at compile time (not from frontmatter): ResolvedRef string `yaml:"-"` // Pinned action reference (e.g., "owner/repo@sha # v1") Inputs map[string]*ActionYAMLInput `yaml:"-"` // Inputs parsed from action.yml ActionDescription string `yaml:"-"` // Description from action.yml }` | SafeOutputActionConfig holds configuration for a single custom safe output action. |
| `safe_outputs_app_config.go` | `GitHubAppConfig` | `type GitHubAppConfig struct { AppID string `yaml:"client-id,omitempty"` // GitHub App client ID (or legacy app ID) (e.g., "${{ vars.APP_ID }}") PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") IgnoreIfMissing bool `yaml:"ignore-if-missing,omitempty"` // If true, skip token minting when client-id/private-key resolve empty Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to checkout.repository owner when derivable, otherwise current repository owner) Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to Permissions map[string]string `yaml:"permissions,omitempty"` // Optional: extra permission-* fields to merge into the minted token (nested wins over job-level) }` | GitHubAppConfig holds configuration for GitHub App-based token minting |
| `safe_outputs_config_runtime.go` | `SafeOutputStepConfig` | `type SafeOutputStepConfig struct { StepName string // Human-readable step name (e.g., "Create Issue") StepID string // Step ID for referencing outputs (e.g., "create_issue") Script string // JavaScript script to execute (for inline mode) ScriptName string // Name of the script in the registry (for file mode) CustomEnvVars []string // Environment variables specific to this step Condition ConditionNode // Step-level condition (if clause) Token string // GitHub token for this step UseCopilotRequestsToken bool // Whether to use Copilot requests token preference chain UseCopilotCodingAgentToken bool // Whether to use Copilot coding agent token preference chain PreSteps []string // Optional steps to run before the script step PostSteps []string // Optional steps to run after the script step Outputs map[string]string // Outputs from this step ContinueOnError bool // Whether to continue the job even if this step fails (continue-on-error: true) }` | SafeOutputStepConfig holds configuration for building a single safe output step within the consolidated safe-outputs job |
Expand Down
37 changes: 37 additions & 0 deletions pkg/workflow/repo_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,43 @@ func (r *RunsOnValue) UnmarshalJSON(data []byte) error {
return nil
}

// toRunsOnValue converts a YAML-decoded runs-on value (a string or a list of
// strings) into a RunsOnValue. Values with an unsupported shape, and non-string
// list entries, are ignored.
func toRunsOnValue(value any) RunsOnValue {

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.

L93-116: yagni: toRunsOnValue reimplements []any/[]string-to-[]string conversion. parseStringSliceAny (pkg/workflow/parse_helpers.go) already does this generically; wrap its result in RunsOnValue(...) and drop the []any/[]string cases.

switch v := value.(type) {
case string:
return RunsOnValue{v}
case []any:
labels := make(RunsOnValue, 0, len(v))
for _, item := range v {
if itemStr, ok := item.(string); ok {
labels = append(labels, itemStr)
}
}
if len(labels) == 0 {
return nil
}
return labels
case []string:
if len(v) == 0 {
return nil
}
return RunsOnValue(v)
default:
return nil
}
}

func isRunsOnArrayValue(value any) bool {
switch value.(type) {
case []any, []string:
return true
default:
return false
}
}

// MaintenanceConfig holds maintenance-workflow-specific settings from aw.json.
type MaintenanceCompileConfig struct {
// CreatePullRequestGitHubToken is the secret name used by the compile-workflows
Expand Down
37 changes: 18 additions & 19 deletions pkg/workflow/safe_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type SafeJobConfig struct {
// Standard GitHub Actions job properties
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
RunsOn any `yaml:"runs-on,omitempty"`
RunsOn RunsOnValue `yaml:"runs-on,omitempty"`
If string `yaml:"if,omitempty"`
Needs []string `yaml:"needs,omitempty"`
Steps []any `yaml:"steps,omitempty"`
Expand All @@ -31,6 +31,7 @@ type SafeJobConfig struct {
GitHubToken string `yaml:"github-token,omitempty"`
Output string `yaml:"output,omitempty"`
Max int `yaml:"max,omitempty"` // Maximum number of times this output type may be emitted per run (default: 1)
runsOnArray bool `yaml:"-"`
}

// parseSafeJobsConfig parses safe-jobs configuration from a jobs map.
Expand Down Expand Up @@ -68,9 +69,11 @@ func (c *Compiler) parseSafeJobsConfig(jobsMap map[string]any) map[string]*SafeJ

// Parse runs-on (also accept "runner" as alias)
if runsOn, exists := jobConfig["runs-on"]; exists {
safeJob.RunsOn = runsOn
safeJob.RunsOn = toRunsOnValue(runsOn)
safeJob.runsOnArray = isRunsOnArrayValue(runsOn)
} else if runner, exists := jobConfig["runner"]; exists {
safeJob.RunsOn = runner
safeJob.RunsOn = toRunsOnValue(runner)
safeJob.runsOnArray = isRunsOnArrayValue(runner)
}

// Parse if condition
Expand Down Expand Up @@ -231,24 +234,20 @@ func (c *Compiler) buildSafeJobs(data *WorkflowData, threatDetectionEnabled bool
// Add any additional dependencies from the config
job.Needs = append(job.Needs, jobConfig.Needs...)

// Set runs-on
if jobConfig.RunsOn != nil {
if runsOnStr, ok := jobConfig.RunsOn.(string); ok {
job.RunsOn = "runs-on: " + runsOnStr
} else if runsOnList, ok := jobConfig.RunsOn.([]any); ok {
// Handle array format
var runsOnItems []string
for _, item := range runsOnList {
if itemStr, ok := item.(string); ok {
runsOnItems = append(runsOnItems, " - "+itemStr)
}
}
if len(runsOnItems) > 0 {
job.RunsOn = "runs-on:\n" + strings.Join(runsOnItems, "\n")
}
const defaultRunsOn = "ubuntu-latest"

// Set runs-on.
// Preserve list-shaped input from safe-outputs.jobs as a YAML array.
if jobConfig.runsOnArray && len(jobConfig.RunsOn) > 0 {
// Keep []string{""} semantically unset, matching FormatRunsOn behavior.
if len(jobConfig.RunsOn) == 1 && jobConfig.RunsOn[0] == "" {
job.RunsOn = "runs-on: " + defaultRunsOn
} else {
job.RunsOn = c.indentYAMLLines(renderRunsOnSnippet([]string(jobConfig.RunsOn)), " ")
}
} else {
job.RunsOn = "runs-on: ubuntu-latest" // Default
// FormatRunsOn handles defaulting and YAML-safe rendering.
job.RunsOn = "runs-on: " + FormatRunsOn(jobConfig.RunsOn, defaultRunsOn)
}

// Set if condition - combine safe output type check with user-provided condition
Expand Down
97 changes: 84 additions & 13 deletions pkg/workflow/safe_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestParseSafeJobsConfig(t *testing.T) {
}

// Test runs-on
if deployJob.RunsOn != "ubuntu-latest" {
if len(deployJob.RunsOn) != 1 || deployJob.RunsOn[0] != "ubuntu-latest" {
t.Errorf("Expected runs-on to be 'ubuntu-latest', got %v", deployJob.RunsOn)
}

Expand Down Expand Up @@ -232,7 +232,7 @@ func TestBuildSafeJobs(t *testing.T) {
SafeOutputs: &SafeOutputsConfig{
Jobs: map[string]*SafeJobConfig{
"deploy": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
If: "github.event.issue.number",
Env: map[string]string{
"DEPLOY_ENV": "production",
Expand Down Expand Up @@ -366,6 +366,77 @@ safe-outputs:
require.Less(t, alphaIdx, zebraIdx, "conclusion job should list safe-jobs in deterministic sorted order")
}

func TestParseAndBuildSafeJobsRunsOnList(t *testing.T) {
c := NewCompiler()

safeJobs := c.parseSafeJobsConfig(map[string]any{
"deploy": map[string]any{
"runs-on": []any{"self-hosted", "linux"},
"steps": []any{
map[string]any{"run": "echo 'Deploying'"},
},
},
})

require.Equal(t, RunsOnValue{"self-hosted", "linux"}, safeJobs["deploy"].RunsOn)
require.True(t, safeJobs["deploy"].runsOnArray)

workflowData := &WorkflowData{
Name: "test-workflow",
SafeOutputs: &SafeOutputsConfig{Jobs: safeJobs},
}

_, err := c.buildSafeJobs(workflowData, false)
require.NoError(t, err)

jobs := c.jobManager.GetAllJobs()
require.Len(t, jobs, 1)

var job *Job
for _, j := range jobs {
job = j
break
}

// Multiple labels parsed as a list are rendered as a YAML list.
require.Equal(t, "runs-on:\n - self-hosted\n - linux", job.RunsOn)
}

func TestParseAndBuildSafeJobsSingleRunsOnList(t *testing.T) {
c := NewCompiler()

safeJobs := c.parseSafeJobsConfig(map[string]any{
"deploy": map[string]any{
"runs-on": []any{"self-hosted"},
"steps": []any{
map[string]any{"run": "echo 'Deploying'"},
},
},
})

require.Equal(t, RunsOnValue{"self-hosted"}, safeJobs["deploy"].RunsOn)
require.True(t, safeJobs["deploy"].runsOnArray)

workflowData := &WorkflowData{
Name: "test-workflow",
SafeOutputs: &SafeOutputsConfig{Jobs: safeJobs},
}

_, err := c.buildSafeJobs(workflowData, false)
require.NoError(t, err)

jobs := c.jobManager.GetAllJobs()
require.Len(t, jobs, 1)

var job *Job
for _, j := range jobs {
job = j
break
}

require.Equal(t, "runs-on:\n - self-hosted", job.RunsOn)
}

func TestBuildSafeJobsWithNoConfiguration(t *testing.T) {
c := NewCompiler()

Expand Down Expand Up @@ -403,7 +474,7 @@ func TestBuildSafeJobsWithoutCustomIfCondition(t *testing.T) {
SafeOutputs: &SafeOutputsConfig{
Jobs: map[string]*SafeJobConfig{
"notify": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
// No custom 'if' condition
Inputs: map[string]*InputDefinition{
"message": {
Expand Down Expand Up @@ -454,7 +525,7 @@ func TestBuildSafeJobsWithDashesInName(t *testing.T) {
SafeOutputs: &SafeOutputsConfig{
Jobs: map[string]*SafeJobConfig{
"send-notification": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
Steps: []any{
map[string]any{
"name": "Send notification",
Expand Down Expand Up @@ -578,21 +649,21 @@ func TestExtractSafeJobsFromFrontmatter(t *testing.T) {
t.Error("Expected 'deploy' job to exist")
}

if deployJob.RunsOn != "ubuntu-latest" {
t.Errorf("Expected runs-on to be 'ubuntu-latest', got '%s'", deployJob.RunsOn)
if len(deployJob.RunsOn) != 1 || deployJob.RunsOn[0] != "ubuntu-latest" {
t.Errorf("Expected runs-on to be 'ubuntu-latest', got '%v'", deployJob.RunsOn)
}
}

func TestMergeSafeJobs(t *testing.T) {
base := map[string]*SafeJobConfig{
"deploy": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
},
}

additional := map[string]*SafeJobConfig{
"test": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
},
}

Expand All @@ -609,7 +680,7 @@ func TestMergeSafeJobs(t *testing.T) {
// Test conflict detection
conflicting := map[string]*SafeJobConfig{
"deploy": {
RunsOn: "windows-latest",
RunsOn: RunsOnValue{"windows-latest"},
},
}

Expand All @@ -631,7 +702,7 @@ func TestMergeSafeJobsFromIncludedConfigs(t *testing.T) {
topSafeJobs := map[string]*SafeJobConfig{
"deploy": {
Name: "Deploy Application",
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
},
}

Expand Down Expand Up @@ -682,8 +753,8 @@ func TestMergeSafeJobsFromIncludedConfigs(t *testing.T) {
t.Error("Expected 'test' job from includes to exist")
}

if testJob.RunsOn != "ubuntu-latest" {
t.Errorf("Expected test job runs-on to be 'ubuntu-latest', got '%s'", testJob.RunsOn)
if len(testJob.RunsOn) != 1 || testJob.RunsOn[0] != "ubuntu-latest" {
t.Errorf("Expected test job runs-on to be 'ubuntu-latest', got '%v'", testJob.RunsOn)
}

notifyJob, exists := result["notify"]
Expand Down Expand Up @@ -727,7 +798,7 @@ func TestBuildSafeJobsEnvExpressionHoisting(t *testing.T) {
SafeOutputs: &SafeOutputsConfig{
Jobs: map[string]*SafeJobConfig{
"publish": {
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
Env: map[string]string{
"GH_TOKEN": "${{ github.token }}",
"STATIC_VAR": "literal-value",
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/safe_outputs_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,7 @@ This workflow imports safe-jobs from a shared workflow.
// Verify job configuration
job := workflowData.SafeOutputs.Jobs["my-custom-job"]
assert.Equal(t, "My Custom Job", job.Name, "Job name should match")
assert.Equal(t, "ubuntu-latest", job.RunsOn, "Job runs-on should match")
assert.Equal(t, RunsOnValue{"ubuntu-latest"}, job.RunsOn, "Job runs-on should match")
assert.Len(t, job.Steps, 1, "Job should have 1 step")
assert.Contains(t, job.Permissions, "contents", "Job should have contents permission")
assert.Contains(t, job.Permissions, "issues", "Job should have issues permission")
Expand Down Expand Up @@ -1397,7 +1397,7 @@ func TestMergeSafeOutputsJobsNotMerged(t *testing.T) {
Jobs: map[string]*SafeJobConfig{
"existing-job": {
Name: "Existing Job",
RunsOn: "ubuntu-latest",
RunsOn: RunsOnValue{"ubuntu-latest"},
},
},
}
Expand Down
Loading