Developer Instructions for GitHub Agentic Workflows
This document consolidates technical specifications and development guidelines for GitHub Agentic Workflows (gh-aw). It provides comprehensive guidance on code organization, validation architecture, security practices, and implementation patterns.
The gh-aw CLI follows context-based capitalization to distinguish between the product name and generic workflow references.
| Context | Format | Example |
|---|---|---|
| Product name | Capitalized | "GitHub Agentic Workflows CLI from GitHub Next" |
| Generic workflows | Lowercase | "Enable agentic workflows" |
| Technical terms | Capitalized | "Compile Markdown workflows to GitHub Actions YAML" |
This convention distinguishes between the product name (GitHub Agentic Workflows) and the concept (agentic workflows), following industry standards similar to "GitHub Actions" vs. "actions".
The capitalization rules are enforced through automated tests in cmd/gh-aw/capitalization_test.go that run as part of the standard test suite.
The codebase follows clear patterns for organizing code by functionality rather than type. This section provides guidance on maintaining code quality and structure.
Organize code into focused files of 100-500 lines rather than creating large monolithic files.
Example:
create_issue.go (160 lines)
create_pull_request.go (238 lines)
create_discussion.go (118 lines)
Recommended approach:
create_issue.go # Issue creation logic
create_issue_test.go # Issue creation tests
add_comment.go # Comment addition logic
add_comment_test.go # Comment tests
Avoid:
models.go # All structs
logic.go # All business logic
tests.go # All tests
One file per GitHub entity creation operation:
create_issue.go - GitHub issue creation logiccreate_pull_request.go - Pull request creation logiccreate_discussion.go - Discussion creation logiccreate_code_scanning_alert.go - Code scanning alert creationBenefits:
Each AI engine has its own file with shared helpers in engine_helpers.go:
copilot_engine.go - GitHub Copilot engineclaude_engine.go - Claude enginecodex_engine.go - Codex enginecustom_engine.go - Custom engine supportengine_helpers.go - Shared engine utilitiesTests live alongside implementation files:
feature.go + feature_test.gofeature_integration_test.gofeature_scenario_test.go| Category | Lines | Use Case | Example |
|---|---|---|---|
| Small files | 50-200 | Utilities, simple features | args.go (65 lines) |
| Medium files | 200-500 | Most feature implementations | create_issue.go (160 lines) |
| Large files | 500-800 | Complex features | permissions.go (905 lines) |
| Very large files | 800+ | Core infrastructure only | compiler.go (1596 lines) |
graph TD
A[Need to add code] --> B{New safe output type?}
B -->|Yes| C[Create create_entity.go]
B -->|No| D{New AI engine?}
D -->|Yes| E[Create engine_name_engine.go]
D -->|No| F{Current file > 800 lines?}
F -->|Yes| G[Consider splitting by boundaries]
F -->|No| H{Functionality independent?}
H -->|Yes| I[Create new file]
H -->|No| J[Add to existing file]
graph TD
A[Evaluating file split] --> B{File > 1000 lines?}
B -->|Yes| C[SHOULD split]
B -->|No| D{File > 800 lines?}
D -->|Yes| E[CONSIDER splitting]
D -->|No| F{Multiple responsibilities?}
F -->|Yes| E
F -->|No| G{Frequent merge conflicts?}
G -->|Yes| E
G -->|No| H[Keep as is]
The refactoring of pkg/parser/frontmatter.go demonstrates applying file organization principles to a large monolithic file.
graph TD
A[frontmatter.go<br/>1,907 LOC] --> B[ansi_strip.go<br/>108 LOC]
A --> C[frontmatter_content.go<br/>284 LOC]
A --> D[remote_fetch.go<br/>258 LOC]
A --> E[workflow_update.go<br/>129 LOC]
A --> F[frontmatter.go<br/>1,166 LOC]
B --> G[ANSI escape<br/>sequence utilities]
C --> H[Frontmatter<br/>parsing & extraction]
D --> I[GitHub remote<br/>content fetching]
E --> J[Workflow file<br/>updates]
F --> K[Core frontmatter<br/>processing]
style B fill:#90EE90
style C fill:#90EE90
style D fill:#90EE90
style E fill:#90EE90
style F fill:#FFE4B5
| Metric | Before | After | Change |
|---|---|---|---|
| Main file size | 1,907 LOC | 1,166 LOC | -741 LOC (-39%) |
| Number of files | 1 | 5 | +4 files |
| Average file size | 1,907 LOC | 233 LOC | -88% |
| Test pass rate | 100% | 100% | No change ✓ |
| Breaking changes | N/A | 0 | None ✓ |
ansi_strip.go (108 LOC)
StripANSI(), isFinalCSIChar(), isCSIParameterChar()frontmatter_content.go (284 LOC)
ExtractFrontmatterFromContent(), ExtractFrontmatterString(), ExtractMarkdownContent(), etc.remote_fetch.go (258 LOC)
downloadIncludeFromWorkflowSpec(), resolveRefToSHA(), downloadFileFromGitHub()workflow_update.go (129 LOC)
UpdateWorkflowFrontmatter(), EnsureToolsSection(), QuoteCronExpressions()Three complex modules remain in the original file (requiring future work):
These remain due to high interdependency, stateful logic, and complex recursive algorithms.
Single file doing everything - split by responsibility instead. The frontmatter.go refactoring demonstrates how a 1,907-line "god file" can be systematically broken down.
Avoid non-descriptive file names like utils.go, helpers.go, misc.go, common.go.
Use specific names like ansi_strip.go, remote_fetch.go, or workflow_update.go that clearly indicate their purpose.
Keep files focused on one domain. Don't mix unrelated functionality in one file.
Split tests by scenario rather than having one massive test file.
Wait until you have 2-3 use cases before extracting common patterns.
Helper files contain shared utility functions used across multiple modules. Follow these guidelines when creating or modifying helper files.
Create a helper file when you have:
Examples of Good Helper Files:
github_cli.go - GitHub CLI wrapping functions (ExecGH, ExecGHWithOutput)config_helpers.go - Safe output configuration parsing (parseLabelsFromConfig, parseTitlePrefixFromConfig)map_helpers.go - Generic map/type utilities (parseIntValue, filterMapKeys)mcp_renderer.go - MCP configuration rendering (RenderGitHubMCPDockerConfig, RenderJSONMCPConfig)Helper file names should be specific and descriptive, not generic:
Good Names:
github_cli.go - Indicates GitHub CLI helpersmcp_renderer.go - Indicates MCP rendering helpersconfig_helpers.go - Indicates configuration parsing helpersAvoid:
helpers.go - Too genericutils.go - Too vaguemisc.go - Indicates poor organizationcommon.go - Doesn't specify domainInclude:
Exclude:
Current Helper Files in pkg/workflow:
| File | Purpose | Functions | Usage |
|---|---|---|---|
github_cli.go | GitHub CLI wrapper | 2 functions | Used by CLI commands and workflow resolution |
config_helpers.go | Safe output config parsing | 5 functions | Used by safe output processors |
map_helpers.go | Generic map/type utilities | 2 functions | Used across workflow compilation |
prompt_step_helper.go | Prompt step generation | 1 function | Used by prompt generators |
mcp_renderer.go | MCP config rendering | Multiple rendering functions | Used by all AI engines |
engine_helpers.go | Shared engine utilities | Agent, npm install helpers | Used by Copilot, Claude, Codex engines |
Avoid creating helper files when:
Example of co-location preference:
// Instead of: helpers.go containing formatStepName() used only by compiler.go
// Do: Put formatStepName() directly in compiler.go
When refactoring helper files:
The MCP rendering functions were moved from engine_helpers.go to mcp_renderer.go because:
Before:
engine_helpers.go (478 lines)
- Agent helpers
- npm install helpers
- MCP rendering functions ← Should be in mcp_renderer.go
After:
engine_helpers.go (213 lines)
- Agent helpers
- npm install helpers
mcp_renderer.go (523 lines)
- MCP rendering functions
- MCP configuration types
The codebase uses two distinct patterns for string processing with different purposes.
Purpose: Remove or replace invalid characters to create valid identifiers, file names, or artifact names.
When to use: When you need to ensure a string contains only valid characters for a specific context (identifiers, YAML artifact names, filesystem paths).
What it does:
Purpose: Standardize format by removing extensions, converting between conventions, or applying consistent formatting rules.
When to use: When you need to convert between different representations of the same logical entity (e.g., file extensions, naming conventions).
What it does:
Sanitize Functions:
SanitizeName(name string, opts *SanitizeOptions) string - Configurable sanitization with custom character preservationSanitizeWorkflowName(name string) string - Sanitizes workflow names for artifact names and file pathsSanitizeIdentifier(name string) string - Creates clean identifiers for user agent stringsNormalize Functions:
normalizeWorkflowName(name string) string - Removes file extensions to get base workflow identifiernormalizeSafeOutputIdentifier(identifier string) string - Converts dashes to underscores for safe output identifiersgraph TD
A[Need to process a string?] --> B{Need to ensure character validity?}
B -->|Yes| C[Use SANITIZE]
C --> D{Artifact name / file path?}
C --> E{Identifier / user agent?}
C --> F{Custom requirements?}
D --> G[SanitizeWorkflowName]
E --> H[SanitizeIdentifier]
F --> I[SanitizeName with options]
B -->|No| J{Need to standardize format?}
J -->|Yes| K[Use NORMALIZE]
K --> L{Remove file extensions?}
K --> M{Convert conventions?}
L --> N[normalizeWorkflowName]
M --> O[normalizeSafeOutputIdentifier]
SanitizeIdentifier when you need a fallback default value for empty results.Don't sanitize already-normalized strings:
// BAD: Sanitizing a normalized workflow name
normalized := normalizeWorkflowName("weekly-research.md")
sanitized := SanitizeWorkflowName(normalized) // Unnecessary!
Don't normalize for character validity:
// BAD: Using normalize for invalid characters
userInput := "My Workflow: Test/Build"
normalized := normalizeWorkflowName(userInput) // Wrong tool!
// normalized = "My Workflow: Test/Build" (unchanged - invalid chars remain)
The validation system ensures workflow configurations are correct, secure, and compatible with GitHub Actions before compilation.
graph LR
WF[Workflow] --> CV[Centralized Validation]
WF --> DV[Domain-Specific Validation]
CV --> validation.go
DV --> strict_mode.go
DV --> pip.go
DV --> npm.go
DV --> expression_safety.go
DV --> engine.go
DV --> mcp-config.go
Location: pkg/workflow/validation.go (782 lines)
Purpose: General-purpose validation that applies across the entire workflow system
Key Functions:
validateExpressionSizes() - Ensures GitHub Actions expression size limitsvalidateContainerImages() - Verifies Docker images exist and are accessiblevalidateRuntimePackages() - Validates runtime package dependenciesvalidateGitHubActionsSchema() - Validates against GitHub Actions YAML schemavalidateNoDuplicateCacheIDs() - Ensures unique cache identifiersvalidateSecretReferences() - Validates secret reference syntaxvalidateRepositoryFeatures() - Checks repository capabilitiesvalidateHTTPTransportSupport() - Validates HTTP transport configurationvalidateWorkflowRunBranches() - Validates workflow run branch configurationWhen to add validation here:
Domain-specific validation is organized into separate files:
Files: pkg/workflow/strict_mode.go, pkg/workflow/validation_strict_mode.go
Enforces security and safety constraints in strict mode:
validateStrictPermissions() - Refuses write permissionsvalidateStrictNetwork() - Requires explicit network configurationvalidateStrictMCPNetwork() - Requires network config on custom MCP serversvalidateStrictBashTools() - Refuses bash wildcard toolsFile: pkg/workflow/pip.go
Validates Python package availability on PyPI:
validatePipPackages() - Validates pip packagesvalidateUvPackages() - Validates uv packagesFile: pkg/workflow/npm.go
Validates NPX package availability on npm registry.
File: pkg/workflow/expression_safety.go
Validates GitHub Actions expression security with allowlist-based validation.
graph TD
A[New Validation Requirement] --> B{Security or strict mode?}
B -->|Yes| C[strict_mode.go]
B -->|No| D{Only applies to one domain?}
D -->|Yes| E{Domain-specific file exists?}
E -->|Yes| F[Add to domain file]
E -->|No| G[Create new domain file]
D -->|No| H{Cross-cutting concern?}
H -->|Yes| I[validation.go]
H -->|No| J{Validates external resources?}
J -->|Yes| K[Domain-specific file]
J -->|No| I
Used for security-sensitive validation with limited set of valid options:
func validateExpressionSafety(content string) error {
matches := expressionRegex.FindAllStringSubmatch(content, -1)
var unauthorizedExpressions []string
for _, match := range matches {
expression := strings.TrimSpace(match[1])
if !isAllowed(expression) {
unauthorizedExpressions = append(unauthorizedExpressions, expression)
}
}
if len(unauthorizedExpressions) > 0 {
return fmt.Errorf("unauthorized expressions: %v", unauthorizedExpressions)
}
return nil
}
Used for validating external dependencies:
func validateDockerImage(image string, verbose bool) error {
cmd := exec.Command("docker", "inspect", image)
output, err := cmd.CombinedOutput()
if err != nil {
pullCmd := exec.Command("docker", "pull", image)
if pullErr := pullCmd.Run(); pullErr != nil {
return fmt.Errorf("docker image not found: %s", image)
}
}
return nil
}
Used for configuration file validation:
func (c *Compiler) validateGitHubActionsSchema(yamlContent string) error {
schema := loadGitHubActionsSchema()
var data interface{}
if err := yaml.Unmarshal([]byte(yamlContent), &data); err != nil {
return err
}
if err := schema.Validate(data); err != nil {
return fmt.Errorf("schema validation failed: %w", err)
}
return nil
}
Used for applying multiple validation checks in sequence:
func (c *Compiler) validateStrictMode(frontmatter map[string]any, networkPermissions *NetworkPermissions) error {
if !c.strictMode {
return nil
}
if err := c.validateStrictPermissions(frontmatter); err != nil {
return err
}
if err := c.validateStrictNetwork(networkPermissions); err != nil {
return err
}
return nil
}
This section outlines security best practices for GitHub Actions workflows based on static analysis tools (actionlint, zizmor, poutine) and security research.
Template injection occurs when untrusted input is used directly in GitHub Actions expressions, allowing attackers to execute arbitrary code or access secrets.
GitHub Actions expressions (${{ }}) are evaluated before workflow execution. If untrusted data (issue titles, PR bodies, comments) flows into these expressions, attackers can inject malicious code.
# VULNERABLE: Direct use of untrusted input