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
3 changes: 2 additions & 1 deletion .github/workflows/bot-detection.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/workflows/hourly-ci-cleaner.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions .github/workflows/issue-monster.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 6 additions & 7 deletions .github/workflows/release.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .github/workflows/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ safe-outputs:
update-release:
jobs:
config:
needs: ["activation"]
needs: ["pre_activation", "activation"]
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.compute_config.outputs.release_tag }}
Expand Down Expand Up @@ -144,7 +144,7 @@ jobs:
core.setOutput('release_tag', releaseTag);
console.log(`✓ Release tag: ${releaseTag}`);
release:
needs: ["activation", "config"]
needs: ["pre_activation", "activation", "config"]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/smoke-gemini.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 27 additions & 4 deletions pkg/workflow/compiler_activation_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/sliceutil"
"github.com/github/gh-aw/pkg/stringutil"
)

Expand Down Expand Up @@ -617,6 +618,18 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// Find custom jobs that depend on pre_activation - these run before activation
customJobsBeforeActivation := c.getCustomJobsDependingOnPreActivation(data.Jobs)

// Find custom jobs whose outputs are referenced in the markdown body but have no explicit needs.
// These jobs must run before activation so their outputs are available when the activation job
// builds the prompt. Without this, activation would reference their outputs while they haven't
// run yet, causing actionlint errors and incorrect prompt substitutions.
promptReferencedJobs := c.getCustomJobsReferencedInPromptWithNoActivationDep(data)
for _, jobName := range promptReferencedJobs {
if !sliceutil.Contains(customJobsBeforeActivation, jobName) {
customJobsBeforeActivation = append(customJobsBeforeActivation, jobName)
compilerActivationJobsLog.Printf("Added '%s' to activation dependencies: referenced in markdown body and has no explicit needs", jobName)
}
}

if preActivationJobCreated {
// Activation job depends on pre-activation job and checks the "activated" output
activationNeeds = []string{string(constants.PreActivationJobName)}
Expand Down Expand Up @@ -667,7 +680,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate

// Generate prompt in the activation job (before agent job runs)
compilerActivationJobsLog.Print("Generating prompt in activation job")
c.generatePromptInActivationJob(&steps, data, preActivationJobCreated)
c.generatePromptInActivationJob(&steps, data, preActivationJobCreated, customJobsBeforeActivation)

// Upload prompt.txt as an artifact for the agent job to download
compilerActivationJobsLog.Print("Adding prompt artifact upload step")
Expand Down Expand Up @@ -807,7 +820,14 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
// through the activation job, if the workflow content directly references their outputs
// (e.g., ${{ needs.search_issues.outputs.* }}), we MUST add them as direct dependencies.
// This is required for GitHub Actions expression evaluation and actionlint validation.
referencedJobs := c.getReferencedCustomJobs(data.MarkdownContent, data.Jobs)
// Also check custom steps from the frontmatter, which are also added to the agent job.
var contentBuilder strings.Builder
contentBuilder.WriteString(data.MarkdownContent)
if data.CustomSteps != "" {
contentBuilder.WriteByte('\n')
contentBuilder.WriteString(data.CustomSteps)
}
referencedJobs := c.getReferencedCustomJobs(contentBuilder.String(), data.Jobs)
for _, jobName := range referencedJobs {
// Skip jobs.pre-activation (or pre_activation) as it's handled specially
if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" {
Expand Down Expand Up @@ -956,14 +976,17 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (

// generatePromptInActivationJob generates the prompt creation steps and adds them to the activation job
// This creates the prompt.txt file that will be uploaded as an artifact and downloaded by the agent job
func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *WorkflowData, preActivationJobCreated bool) {
// beforeActivationJobs is the list of custom job names that run before (i.e., are dependencies of) activation.
// Passing nil or an empty slice means no custom jobs run before activation; expressions referencing any
// custom job will be filtered out of the substitution step to avoid actionlint errors.
func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *WorkflowData, preActivationJobCreated bool, beforeActivationJobs []string) {
compilerActivationJobsLog.Print("Generating prompt steps in activation job")

// Use a string builder to collect the YAML
var yaml strings.Builder

// Call the existing generatePrompt method to get all the prompt steps
c.generatePrompt(&yaml, data, preActivationJobCreated)
c.generatePrompt(&yaml, data, preActivationJobCreated, beforeActivationJobs)

// Append the generated YAML content as a single string to steps
yamlContent := yaml.String()
Expand Down
75 changes: 70 additions & 5 deletions pkg/workflow/compiler_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,23 @@ func jobDependsOnPreActivation(jobConfig map[string]any) bool {
return false
}

// jobDependsOnActivation checks if a job config has activation as a dependency.
// Jobs that depend on activation run AFTER activation, not before it.
func jobDependsOnActivation(jobConfig map[string]any) bool {
if needs, hasNeeds := jobConfig["needs"]; hasNeeds {
if needsList, ok := needs.([]any); ok {
for _, need := range needsList {
if needStr, ok := need.(string); ok && needStr == string(constants.ActivationJobName) {
return true
}
}
} else if needStr, ok := needs.(string); ok && needStr == string(constants.ActivationJobName) {
return true
}
}
return false
}

// jobDependsOnAgent checks if a job config has agent as a dependency.
// Jobs that depend on agent should run AFTER the agent job, not before it.
// The jobConfig parameter is expected to be a map representing the job's YAML configuration,
Expand All @@ -86,13 +103,15 @@ func jobDependsOnAgent(jobConfig map[string]any) bool {
return false
}

// getCustomJobsDependingOnPreActivation returns custom job names that explicitly depend on pre_activation.
// These jobs run after pre_activation but before activation, and activation should depend on them.
// getCustomJobsDependingOnPreActivation returns custom job names that explicitly depend on pre_activation
// but NOT on activation. These jobs run after pre_activation but before activation, and activation
// should depend on them. Jobs that also depend on activation cannot run before activation.
func (c *Compiler) getCustomJobsDependingOnPreActivation(customJobs map[string]any) []string {
compilerJobsLog.Printf("Finding custom jobs depending on pre_activation: total_custom_jobs=%d", len(customJobs))
deps := sliceutil.FilterMapKeys(customJobs, func(jobName string, jobConfig any) bool {
if configMap, ok := jobConfig.(map[string]any); ok {
return jobDependsOnPreActivation(configMap)
// Must depend on pre_activation AND must NOT depend on activation
return jobDependsOnPreActivation(configMap) && !jobDependsOnActivation(configMap)
}
return false
})
Expand Down Expand Up @@ -120,6 +139,38 @@ func (c *Compiler) getReferencedCustomJobs(content string, customJobs map[string
return refs
}

// getCustomJobsReferencedInPromptWithNoActivationDep returns custom jobs whose outputs are referenced
// in the markdown body content but have no explicit needs (and therefore no activation dependency).
// These jobs need to run before the activation job so their outputs are available when the
// activation job builds the prompt. Without this, activation's prompt-building steps would reference
// those job outputs before the jobs have run, causing actionlint errors and empty substitutions.
//
// Only jobs with NO explicit needs are returned - jobs that explicitly depend on activation/pre_activation/etc.
// are excluded because they either already run before activation or cannot run before it.
func (c *Compiler) getCustomJobsReferencedInPromptWithNoActivationDep(data *WorkflowData) []string {
if data == nil || data.Jobs == nil || data.MarkdownContent == "" {
return nil
}

referencedJobs := c.getReferencedCustomJobs(data.MarkdownContent, data.Jobs)
var result []string
for _, jobName := range referencedJobs {
jobConfig, ok := data.Jobs[jobName].(map[string]any)
if !ok {
continue
}
// Only include jobs with no explicit needs - those get activation auto-added normally.
// Jobs with explicit needs either already run before activation (pre_activation dependency)
// or explicitly depend on activation/agent and must run after.
if _, hasNeeds := jobConfig["needs"]; hasNeeds {
continue
}
result = append(result, jobName)
compilerJobsLog.Printf("Found custom job '%s' referenced in markdown body with no explicit needs: will run before activation", jobName)
}
return result
}

// buildJobs creates all jobs for the workflow and adds them to the job manager.
// This function orchestrates the building of all job types by delegating to focused helper functions.
func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error {
Expand Down Expand Up @@ -360,6 +411,15 @@ func (c *Compiler) extractJobsFromFrontmatter(frontmatter map[string]any) map[st
// buildCustomJobs creates custom jobs defined in the frontmatter jobs section
func (c *Compiler) buildCustomJobs(data *WorkflowData, activationJobCreated bool) error {
compilerJobsLog.Printf("Building %d custom jobs", len(data.Jobs))

// Pre-compute jobs referenced in the markdown body with no explicit needs.
// These run before activation (not after), so we must not auto-add activation to them.
promptReferencedJobsSlice := c.getCustomJobsReferencedInPromptWithNoActivationDep(data)
promptReferencedJobs := make(map[string]bool, len(promptReferencedJobsSlice))
for _, j := range promptReferencedJobsSlice {
promptReferencedJobs[j] = true
}

for jobName, jobConfig := range data.Jobs {
// Skip jobs.pre-activation (or pre_activation) as it's handled specially in buildPreActivationJob
if jobName == string(constants.PreActivationJobName) || jobName == "pre-activation" {
Expand Down Expand Up @@ -389,10 +449,15 @@ func (c *Compiler) buildCustomJobs(data *WorkflowData, activationJobCreated bool
}

// If no explicit needs and activation job exists, automatically add activation as dependency
// This ensures custom jobs wait for workflow validation before executing
if !hasExplicitNeeds && activationJobCreated {
// This ensures custom jobs wait for workflow validation before executing.
// Exception: jobs whose outputs are referenced in the markdown body run before activation
// (so the activation job can include their outputs in the prompt).
isReferencedInMarkdown := promptReferencedJobs[jobName]
if !hasExplicitNeeds && activationJobCreated && !isReferencedInMarkdown {
job.Needs = append(job.Needs, string(constants.ActivationJobName))
compilerJobsLog.Printf("Added automatic dependency: custom job '%s' now depends on '%s'", jobName, string(constants.ActivationJobName))
} else if !hasExplicitNeeds && isReferencedInMarkdown {
compilerJobsLog.Printf("Custom job '%s' referenced in markdown body runs before activation (no auto-added dependency)", jobName)
}

// Extract other job properties
Expand Down
Loading
Loading