Composite Actions Architecture - lukeocodes/wikiinator GitHub Wiki
Why We Chose Composite Actions
When building this GitHub Action, we had several architecture options. Here's what we learned about composite actions and why we chose them.
Action Types Comparison
1. Docker Actions
runs:
using: "docker"
image: "Dockerfile"
Pros:
- Complete control over environment
- Can use any programming language
- Consistent execution environment
Cons:
- Slower startup (build container)
- Larger resource usage
- More complex to maintain
2. JavaScript Actions
runs:
using: "node20"
main: "dist/index.js"
Pros:
- Fast startup
- Rich ecosystem (npm packages)
- Good debugging tools
Cons:
- Need to compile/bundle code
- Node.js specific
- Larger repo size with dependencies
3. Composite Actions (Our Choice)
runs:
using: "composite"
steps:
- shell: bash
run: ${{ github.action_path }}/script.sh
Pros:
- Fast startup (no container build)
- Direct shell script execution
- Easy to understand and maintain
- Cross-platform compatible
- No compilation step
Cons:
- Limited to shell scripting
- Less programmatic control
- Fewer debugging tools
Key Architectural Decisions
1. Single Script Approach
What we chose:
runs:
using: "composite"
steps:
- name: Sync Documentation
shell: bash
run: ${{ github.action_path }}/sync-docs.sh
Why:
- All logic in one place
- Easier to debug
- Simpler to maintain
- Better error handling control
Alternative (multi-step):
runs:
using: "composite"
steps:
- name: Validate inputs
shell: bash
run: ${{ github.action_path }}/validate.sh
- name: Clone wiki
shell: bash
run: ${{ github.action_path }}/clone.sh
- name: Sync files
shell: bash
run: ${{ github.action_path }}/sync.sh
2. Environment Variable Pattern
Input handling:
# action.yml
env:
INPUT_GITHUB_TOKEN: ${{ inputs.github-token || github.token }}
INPUT_DOCS_PATH: ${{ inputs.docs-path }}
GITHUB_REPOSITORY: ${{ github.repository }}
Benefits:
- Standard GitHub Actions pattern
- Automatic INPUT_ prefix
- Easy to access in shell script
- Clear separation of concerns
3. Error Handling Strategy
Composite action limitations:
- Can't use action-specific error formatting in intermediate steps
- Must rely on shell exit codes
- Limited debugging capabilities
Our solution:
# Use GitHub Actions logging format
echo "::error::Error message"
echo "::warning::Warning message"
echo "::debug::Debug message"
# Custom formatting for better UX
log_error() {
echo -e "${RED}[sync-docs]${NC} $1"
}
4. Output Handling
The challenge: Composite actions need to set outputs for the action itself, not just individual steps.
Our solution:
# action.yml
outputs:
files-synced:
description: "Number of files synced"
value: ${{ steps.sync-docs.outputs.files-synced }}
# In script
echo "files-synced=$files_synced" >> "$GITHUB_OUTPUT"
Lessons Learned
1. Path References
Use action path for scripts:
run: ${{ github.action_path }}/sync-docs.sh
Not relative paths:
run: ./sync-docs.sh # ❌ Won't work
2. Shell Selection
Always specify shell:
steps:
- shell: bash # ✅ Explicit
run: ${{ github.action_path }}/script.sh
Different shells for different needs:
bash
- Most compatible, rich featuressh
- More portable, fewer featurespwsh
- PowerShell for Windows compatibility
3. File Permissions
Make scripts executable:
chmod +x sync-docs.sh
Or use explicit shell invocation:
run: bash ${{ github.action_path }}/sync-docs.sh
4. Cross-Platform Considerations
File paths:
# Good - works on all platforms
action_path="${{ github.action_path }}"
script_path="$action_path/sync-docs.sh"
# Bad - Windows issues
script_path="${{ github.action_path }}/sync-docs.sh"
Command compatibility:
# Check command availability
if command -v stat >/dev/null 2>&1; then
file_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null)
else
file_size="unknown"
fi
Best Practices We Developed
1. Structure
your-action/
├── action.yml # Action metadata
├── sync-docs.sh # Main script (executable)
├── README.md # User documentation
├── LICENSE # License file
└── docs/ # Architecture docs
├── architecture.md
└── troubleshooting.md
2. Input Validation
# Early validation
if [ -z "$INPUT_GITHUB_TOKEN" ]; then
echo "::error::GitHub token is required"
exit 1
fi
3. Environment Setup
# Set error handling
set -e # Exit on error (but be careful with arithmetic)
# Set up logging functions
log() { echo -e "${BLUE}[action]${NC} $1"; }
log_error() { echo -e "${RED}[action]${NC} $1"; }
4. Clean Exit Handling
# Always set outputs, even on early exit
cleanup() {
if [ -n "$GITHUB_OUTPUT" ]; then
echo "files-synced=${files_synced:-0}" >> "$GITHUB_OUTPUT"
echo "changes-made=${changes_made:-false}" >> "$GITHUB_OUTPUT"
fi
}
# Set trap for cleanup
trap cleanup EXIT
When to Choose Composite Actions
Choose composite actions when:
- Primary logic is shell-based operations
- Need fast startup times
- Want simple maintenance
- Working with Git operations
- Building utility/automation actions
Choose JavaScript actions when:
- Need complex data manipulation
- Want rich error handling
- Building integrations with APIs
- Need npm package dependencies
Choose Docker actions when:
- Need specific runtime environment
- Using languages other than JavaScript/Shell
- Need system-level dependencies
- Building complex, stateful applications
Our choice of composite actions proved ideal for a Git-based documentation synchronization tool.