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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2362,3 +2362,57 @@ jobs:
name: test-result-integration-unauthenticated
path: test-result-integration-unauthenticated.json
retention-days: 14

integration-add-dispatch-workflow:
name: Integration Add with dispatch-workflow Dependencies
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}-integration-add-dispatch-workflow
cancel-in-progress: true
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Go
id: setup-go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: go.mod
cache: true

- name: Report Go cache status
run: |
if [ "${{ steps.setup-go.outputs.cache-hit }}" == "true" ]; then
echo "✅ Go cache hit" >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ Go cache miss" >> $GITHUB_STEP_SUMMARY
fi

- name: Download dependencies
run: go mod download

- name: Verify dependencies
run: go mod verify

- name: Build gh-aw binary
run: make build

- name: Run dispatch-workflow add integration tests
env:
GH_TOKEN: ${{ github.token }}
run: |
set -o pipefail
go test -v -parallel=4 -timeout=10m -tags 'integration' -json \
-run 'TestAddWorkflowWithDispatchWorkflow' \
./pkg/cli/ \
| tee test-result-integration-add-dispatch-workflow.json

- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-result-integration-add-dispatch-workflow
path: test-result-integration-add-dispatch-workflow.json
retention-days: 14
22 changes: 22 additions & 0 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ func addWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, o
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch frontmatter import dependencies: %v", err)))
}
}
// Fetch and save workflows referenced in safe-outputs.dispatch-workflow so they are
// available locally. Workflow names using GitHub Actions expression syntax are skipped.
if err := fetchAndSaveRemoteDispatchWorkflows(string(sourceContent), workflowSpec, githubWorkflowsDir, opts.Verbose, opts.Force, tracker); err != nil {
return err
}
// Fetch files listed in the 'resources:' frontmatter field (additional workflow or
// action files that should be present alongside this workflow).
if err := fetchAndSaveRemoteResources(string(sourceContent), workflowSpec, githubWorkflowsDir, opts.Verbose, opts.Force, tracker); err != nil {
return err
}
} else if sourceInfo != nil && sourceInfo.IsLocal {
// For local workflows, collect and copy include dependencies from local paths
// The source directory is derived from the workflow's path
Expand Down Expand Up @@ -507,6 +517,18 @@ func addWorkflowWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, o
}
}

// For remote workflows: now that the main workflow and all its imports are on disk,
// parse the fully merged safe-outputs configuration to discover any dispatch workflows
// that originate from imported shared workflows (not visible in the raw frontmatter).
if !isLocalWorkflowPath(workflowSpec.WorkflowPath) {
fetchAndSaveDispatchWorkflowsFromParsedFile(destFile, workflowSpec, githubWorkflowsDir, opts.Verbose, opts.Force, tracker)
}

// Compile any dispatch-workflow .md dependencies that were just fetched and lack a
// .lock.yml. The dispatch-workflow validator requires every .md dispatch target to be
// compiled before the main workflow can be validated.
compileDispatchWorkflowDependencies(destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker)

// Compile the workflow
if tracker != nil {
if err := compileWorkflowWithTracking(destFile, opts.Verbose, opts.Quiet, opts.EngineOverride, tracker); err != nil {
Expand Down
126 changes: 126 additions & 0 deletions pkg/cli/add_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,3 +886,129 @@ func TestAddPublicWorkflowUnauthenticated(t *testing.T) {
_, err = os.Stat(workflowFile)
require.NoError(t, err, "downloaded workflow file should exist at %s", workflowFile)
}

// TestAddWorkflowWithDispatchWorkflowDependency tests that when a remote workflow is added
// that references dispatch-workflow dependencies, those dependency workflows are automatically
// fetched alongside the main workflow.
//
// The test installs test-dispatcher.md from the main branch of github/gh-aw. That workflow
// has:
//
// safe-outputs:
// dispatch-workflow:
// workflows:
// - test-workflow
//
// After `gh aw add`, both test-dispatcher.md AND test-workflow.md should be present locally.
// This test requires GitHub authentication.
func TestAddWorkflowWithDispatchWorkflowDependency(t *testing.T) {
// Skip if GitHub authentication is not available
authCmd := exec.Command("gh", "auth", "status")
if err := authCmd.Run(); err != nil {
t.Skip("Skipping test: GitHub authentication not available (gh auth status failed)")
}

setup := setupAddIntegrationTest(t)
defer setup.cleanup()

// Add test-dispatcher.md which has a dispatch-workflow dependency on test-workflow.
// Use an explicit path spec so the file resolves unambiguously from the main branch.
workflowSpec := "github/gh-aw/.github/workflows/test-dispatcher.md@main"

cmd := exec.Command(setup.binaryPath, "add", workflowSpec, "--verbose")
cmd.Dir = setup.tempDir
output, err := cmd.CombinedOutput()
outputStr := string(output)

t.Logf("Command output:\n%s", outputStr)

require.NoError(t, err, "add command should succeed: %s", outputStr)

workflowsDir := filepath.Join(setup.tempDir, ".github", "workflows")

// 1. The main workflow must be present.
mainFile := filepath.Join(workflowsDir, "test-dispatcher.md")
_, err = os.Stat(mainFile)
require.NoError(t, err, "main workflow test-dispatcher.md should exist at %s", mainFile)

// 2. The dispatch-workflow dependency (test-workflow.md) must be fetched automatically.
depFile := filepath.Join(workflowsDir, "test-workflow.md")
_, err = os.Stat(depFile)
require.NoError(t, err,
"dispatch-workflow dependency test-workflow.md should be auto-fetched alongside the main workflow")

// 3. Both .lock.yml files should be present (compilation must succeed).
mainLock := filepath.Join(workflowsDir, "test-dispatcher.lock.yml")
_, err = os.Stat(mainLock)
require.NoError(t, err, "compiled lock file test-dispatcher.lock.yml should exist")

depLock := filepath.Join(workflowsDir, "test-workflow.lock.yml")
_, err = os.Stat(depLock)
require.NoError(t, err, "compiled lock file test-workflow.lock.yml should exist")

// 4. Verify the dependency file has valid frontmatter.
depContent, err := os.ReadFile(depFile)
require.NoError(t, err, "should be able to read test-workflow.md")
assert.Contains(t, string(depContent), "workflow_dispatch",
"test-workflow.md should have workflow_dispatch trigger")
}

// TestAddWorkflowWithDispatchWorkflowFromSharedImport tests that dispatch-workflow
// configuration is fetched and preserved correctly when `gh aw add` is used.
// This exercises the post-write parse path (fetchAndSaveDispatchWorkflowsFromParsedFile)
// that re-parses the written workflow file to discover any remaining dependencies.
//
// smoke-copilot.md has `safe-outputs.dispatch-workflow: [haiku-printer]` in its own
// frontmatter. haiku-printer exists as a plain GitHub Actions workflow (.yml), not an
// agentic workflow (.md). The dispatch-workflow fetcher first tries haiku-printer.md
// (404), then falls back to haiku-printer.yml which succeeds. The test verifies that
// the overall add command succeeds and the compiled lock file references haiku-printer.
// This test requires GitHub authentication.
func TestAddWorkflowWithDispatchWorkflowFromSharedImport(t *testing.T) {
// Skip if GitHub authentication is not available
authCmd := exec.Command("gh", "auth", "status")
if err := authCmd.Run(); err != nil {
t.Skip("Skipping test: GitHub authentication not available (gh auth status failed)")
}

setup := setupAddIntegrationTest(t)
defer setup.cleanup()

// smoke-copilot.md has `safe-outputs.dispatch-workflow: [haiku-printer]` in its own
// frontmatter. haiku-printer lives as haiku-printer.yml (a plain GitHub Actions
// workflow). The fetcher falls back to .yml when .md is 404, so both the main
// workflow and the dispatch-workflow dependency are written to disk.
workflowSpec := "github/gh-aw/.github/workflows/smoke-copilot.md@main"

cmd := exec.Command(setup.binaryPath, "add", workflowSpec, "--verbose")
cmd.Dir = setup.tempDir
output, err := cmd.CombinedOutput()
outputStr := string(output)

t.Logf("Command output:\n%s", outputStr)

require.NoError(t, err, "add command should succeed: %s", outputStr)

workflowsDir := filepath.Join(setup.tempDir, ".github", "workflows")

// 1. The main workflow must be present.
mainFile := filepath.Join(workflowsDir, "smoke-copilot.md")
_, err = os.Stat(mainFile)
require.NoError(t, err, "main workflow smoke-copilot.md should exist at %s", mainFile)

// 2. haiku-printer.yml should have been fetched via the .yml fallback path.
haikuFile := filepath.Join(workflowsDir, "haiku-printer.yml")
_, err = os.Stat(haikuFile)
require.NoError(t, err, "dispatch-workflow dependency haiku-printer.yml should be fetched")

// 3. Verify compilation succeeded (the lock file was created).
mainLock := filepath.Join(workflowsDir, "smoke-copilot.lock.yml")
_, err = os.Stat(mainLock)
require.NoError(t, err, "compiled lock file smoke-copilot.lock.yml should exist")

// Verify the lock file references the dispatch-workflow configuration
lockContent, err := os.ReadFile(mainLock)
require.NoError(t, err, "should be able to read lock file")
assert.Contains(t, string(lockContent), "haiku-printer",
"lock file should reference the haiku-printer dispatch-workflow target")
}
47 changes: 46 additions & 1 deletion pkg/cli/add_workflow_compilation.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,52 @@ func compileWorkflowWithTrackingAndRefresh(filePath string, verbose bool, quiet
return nil
}

// addSourceToWorkflow adds the source field to the workflow's frontmatter.
// compileDispatchWorkflowDependencies compiles any dispatch-workflow .md dependencies of
// workflowFile that are present locally but lack a corresponding .lock.yml. This must be
// called before compiling the main workflow, because the dispatch-workflow validator
// requires every referenced .md workflow to have an up-to-date .lock.yml.
func compileDispatchWorkflowDependencies(workflowFile string, verbose, quiet bool, engineOverride string, tracker *FileTracker) {
// Parse the merged safe-outputs to get the canonical list of dispatch-workflow names.
compiler := workflow.NewCompiler()
data, err := compiler.ParseWorkflowFile(workflowFile)
if err != nil || data == nil || data.SafeOutputs == nil || data.SafeOutputs.DispatchWorkflow == nil {
return
}

workflowsDir := filepath.Dir(workflowFile)

for _, name := range data.SafeOutputs.DispatchWorkflow.Workflows {
mdPath := filepath.Join(workflowsDir, name+".md")
lockPath := stringutil.MarkdownToLockFile(mdPath)

// Only compile if the .md is present but the .lock.yml is absent.
if _, mdErr := os.Stat(mdPath); mdErr != nil {
continue // .md doesn't exist locally
}
if _, lockErr := os.Stat(lockPath); lockErr == nil {
continue // .lock.yml already exists, nothing to do
}

addWorkflowCompilationLog.Printf("Compiling dispatch-workflow dependency: %s", mdPath)
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Compiling dispatch-workflow dependency: "+mdPath))
}

var compileErr error
if tracker != nil {
compileErr = compileWorkflowWithTracking(mdPath, verbose, quiet, engineOverride, tracker)
} else {
compileErr = compileWorkflow(mdPath, verbose, quiet, engineOverride)
}
if compileErr != nil {
// Best-effort: log and continue so the main workflow can still give a clear error.
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to compile dispatch-workflow dependency %s: %v", mdPath, compileErr)))
}
}
}
}

// This function preserves the existing frontmatter formatting while adding the source field.
func addSourceToWorkflow(content, source string) (string, error) {
// Use shared frontmatter logic that preserves formatting
Expand Down
Loading
Loading