Home > Developer Guide > CD > Dynamic Runner Selection
GitHub Actions Dynamic Runner Selection Pattern
Purpose: Enable dynamic runtime selection between self-hosted and GitHub-hosted runners
Context: Developed during PR #269 debugging when self-hosted runner was offline overnight
Key Feature: Priority-based selection with commit message/PR title flags
Overview
The Problem
Scenario: Self-hosted runner offline, need to run CI on GitHub-hosted runners
Challenges:
- Workflows hardcoded to specific runner type
- Can’t change workflow without new commit
- Self-hosted runner offline → can’t run decision logic
- Need flexible, runtime-configurable runner selection
The Solution
Priority-based runner selection system:
- Priority 1: Commit message flags (
[github],[self-hosted],[skip-ci]) - Priority 2: PR title flags (persistent, API-fetched)
- Priority 3: Manual workflow_dispatch input
- Priority 4: Day-based default (quota management)
Key Innovation: Decision job runs on ubuntu-latest (always available), not self-hosted
Architecture
Workflow Structure
name: Android CI
on:
pull_request:
workflow_dispatch:
inputs:
runner_type:
description: 'Runner type'
required: false
type: choice
options:
- self-hosted
- github-hosted
jobs:
decide_runner:
name: Decide Runner
runs-on: ubuntu-latest # Always available
outputs:
runner: ${{ steps.decide.outputs.runner }}
steps:
- uses: actions/checkout@v4
- name: Determine runner to use
id: decide
shell: pwsh # Cross-platform PowerShell
run: |
# Decision logic here
echo "runner=github" >> $env:GITHUB_OUTPUT
build:
name: Build
needs: decide_runner
runs-on: ${{ needs.decide_runner.outputs.runner == 'github' && 'ubuntu-latest' || 'self-hosted' }}
steps:
# Build stepsKey Points:
decide_runnerjob runs onubuntu-latest(not self-hosted)- Outputs runner choice to other jobs
- Other jobs consume output via
needs.decide_runner.outputs.runner
Implementation
Decision Logic (PowerShell)
File: .github/workflows/android-ci.yml
- name: Determine runner to use
id: decide
shell: pwsh # PowerShell Core (works on Linux)
run: |
# Priority 1: Commit message flags (highest priority)
$commitMessage = git log -1 --pretty=%B
Write-Host "Commit message: $commitMessage"
if ($commitMessage -match '\[skip-ci\]') {
Write-Host "Found [skip-ci] flag - skipping CI"
echo "runner=skip" >> $env:GITHUB_OUTPUT
exit 0
}
if ($commitMessage -match '\[github\]') {
Write-Host "Found [github] flag in commit - using GitHub runner"
echo "runner=github" >> $env:GITHUB_OUTPUT
exit 0
}
if ($commitMessage -match '\[self-hosted\]') {
Write-Host "Found [self-hosted] flag in commit - using self-hosted runner"
echo "runner=self-hosted" >> $env:GITHUB_OUTPUT
exit 0
}
# Priority 2: PR title flags (persistent setting)
$prTitle = gh pr view ${{ github.event.pull_request.number }} --json title -q .title
Write-Host "PR title: $prTitle"
if ($prTitle -match '\[github\]') {
Write-Host "Found [github] flag in PR title - using GitHub runner"
echo "runner=github" >> $env:GITHUB_OUTPUT
exit 0
}
if ($prTitle -match '\[self-hosted\]') {
Write-Host "Found [self-hosted] flag in PR title - using self-hosted runner"
echo "runner=self-hosted" >> $env:GITHUB_OUTPUT
exit 0
}
# Priority 3: Manual workflow_dispatch input
$manualInput = "${{ github.event.inputs.runner_type }}"
if ($manualInput -and $manualInput -ne "") {
if ($manualInput -eq "github-hosted") {
Write-Host "Manual input: github-hosted"
echo "runner=github" >> $env:GITHUB_OUTPUT
exit 0
} elseif ($manualInput -eq "self-hosted") {
Write-Host "Manual input: self-hosted"
echo "runner=self-hosted" >> $env:GITHUB_OUTPUT
exit 0
}
}
# Priority 4: Day-based default (quota management)
$day = (Get-Date).Day
Write-Host "Current day of month: $day"
if ($day -ge 26) {
Write-Host "Days 26-31: Using GitHub-hosted runners (month-end quota available)"
echo "runner=github" >> $env:GITHUB_OUTPUT
} else {
Write-Host "Days 1-25: Using self-hosted runners (preserve GitHub quota)"
echo "runner=self-hosted" >> $env:GITHUB_OUTPUT
}Flag Usage
Commit Message Flags
Format: Include flag in commit message
Examples:
# Force GitHub-hosted runner
git commit -m "fix: Update dependencies [github]"
# Force self-hosted runner
git commit -m "test: Run on self-hosted [self-hosted]"
# Skip CI entirely
git commit -m "docs: Update README [skip-ci]"Priority: Highest (overrides everything)
Scope: Single commit only
Use Case: Quick one-time override
PR Title Flags
Format: Include flag in PR title
Examples:
[github] deps: Update Gradle dependencies
[self-hosted] feat: Add new feature
Priority: Second (after commit message)
Scope: Entire PR (persistent)
Use Case: PR requires specific runner type for all commits
How It Works:
- Workflow fetches current PR title via GitHub API (
gh pr view) - Not using stale
github.event.pull_request.title(cached at workflow start) - Checks for flags in fetched title
- Applies to all commits in PR unless commit message overrides
Manual Workflow Dispatch
Format: Run workflow manually with input selection
Steps:
- Go to Actions tab in GitHub
- Select “Android CI” workflow
- Click “Run workflow”
- Select runner type from dropdown
- Click “Run workflow”
Priority: Third (after commit message and PR title)
Use Case: Manual testing, emergency runs
Day-Based Default
Logic:
- Days 1-25: Use self-hosted runners
- Days 26-31: Use GitHub-hosted runners
Rationale:
- GitHub Actions has monthly quota limits
- Quota resets on 1st of each month
- Save GitHub quota early in month
- Use remaining quota end of month
Priority: Lowest (fallback when no flags present)
Use Case: Automatic quota management
Problems Solved
Problem 1: Chicken-Egg with Self-Hosted Decision Logic
Original Issue:
decide_runner:
runs-on: self-hosted # ❌ Needs self-hosted to decide if self-hosted available!Problem:
- If self-hosted runner offline, decision job can’t run
- Can’t decide to use GitHub runner because decision requires self-hosted runner
- Workflow stuck
Solution:
decide_runner:
runs-on: ubuntu-latest # ✅ Always availableResult: Decision logic always runs, even when self-hosted offline
Commit: 7f779915 - ci: Force GitHub-hosted runner + fix decide_runner to ubuntu-latest
Problem 2: PowerShell vs pwsh on Linux
Original Issue:
- name: Decide runner
shell: powershell # ❌ Doesn't exist on Linux runners
run: |
# PowerShell commandsError:
/usr/bin/bash: powershell: command not found
Problem:
powershell= Windows PowerShell (Windows-only)- GitHub-hosted Ubuntu runners don’t have Windows PowerShell
- Workflow fails on GitHub-hosted runners
Solution:
- name: Decide runner
shell: pwsh # ✅ PowerShell Core (cross-platform)
run: |
# PowerShell commandsResult: Decision logic works on both Linux and Windows runners
Commit: dc3f0b9b - fix: Use pwsh instead of powershell for Linux runner
Problem 3: Stale PR Title in Event Data
Original Issue:
- name: Check PR title
run: |
$prTitle = "${{ github.event.pull_request.title }}"
# Check for flags...Problem:
github.event.pull_request.titlecached at workflow start- If you add
[github]to PR title after workflow triggered, it’s not seen - Workflow uses old title without flag
Example Timeline:
- 00:00 - Push commit, workflow triggers
- 00:01 - Realize need GitHub runner
- 00:02 - Add
[github]to PR title - 00:03 - Workflow checks title → sees old title (no
[github]) - 00:04 - Workflow uses wrong runner
Solution:
- name: Check PR title
run: |
$prTitle = gh pr view ${{ github.event.pull_request.number }} --json title -q .title
# Check for flags in CURRENT titleResult: Always fetches current PR title, not cached version
Commit: 5c6c047b - fix: Use PR title as source of truth for runner selection
Problem 4: Merge Commits in PR Context
Original Issue:
- name: Check commit message
run: |
$commitMessage = git log -1 --pretty=%B
# Check for flags...Problem:
- In PR context, GitHub creates merge commits
git log -1shows merge commit message (not actual commit)- Merge commit:
"Merge abc123 into def456"(no flags) - Actual commit:
"fix: Update deps [github]"(has flag)
Investigation:
git log -1 --pretty=%B
# Output: Merge 72bb86ce into 59ddf08
# Expected: fix: Revert to hardcoded Firebase BOM 33.0.0 [github]Solution: Priority system checks PR title as fallback:
- Try commit message first (might be merge commit)
- If no flag in commit, check PR title
- PR title flags work regardless of merge commits
Alternative Solution (Not Implemented):
# Could check actual commit instead of merge commit
$commitMessage = git log -2 --pretty=%B | Select-Object -Last 1Result: PR title flags always work, commit flags work when not merge commit
Commit: 48c4d7e3 - fix: Fetch current PR title via API and check commit message priority
Usage Examples
Example 1: Overnight Build with Offline Self-Hosted Runner
Scenario: Self-hosted runner offline overnight, need to run CI
Solution:
# Option A: Add [github] to PR title
# Title: "[github] deps: Update Gradle dependencies"
# All commits in PR use GitHub runner
# Option B: Add [github] to commit message
git commit --allow-empty -m "ci: Force GitHub runner [github]"
git push
# Single commit uses GitHub runner
# Option C: Use workflow_dispatch
# Go to Actions tab > Android CI > Run workflow > Select "github-hosted"Result: CI runs on GitHub-hosted runners despite self-hosted being offline
Example 2: Testing Self-Hosted Specific Features
Scenario: PR adds self-hosted specific caching logic, must test on self-hosted
Solution:
# PR title: "[self-hosted] feat: Add Gradle build cache optimization"
Result: All commits in PR run on self-hosted runner, even during days 26-31 when default would be GitHub
Example 3: Documentation-Only Changes
Scenario: PR only updates documentation, no need to run CI
Solution:
git commit -m "docs: Update API documentation [skip-ci]"Result: CI workflow skipped entirely, saving quota and time
Configuration
Enable in Your Workflow
Step 1: Add decision job:
decide_runner:
name: Decide Runner
runs-on: ubuntu-latest # Must be always-available runner
outputs:
runner: ${{ steps.decide.outputs.runner }}
steps:
- uses: actions/checkout@v4
- name: Determine runner to use
id: decide
shell: pwsh
env:
GH_TOKEN: ${{ github.token }} # Required for gh CLI
run: |
# Copy decision logic from implementation sectionStep 2: Update dependent jobs:
build:
name: Build
needs: decide_runner
runs-on: ${{ needs.decide_runner.outputs.runner == 'github' && 'ubuntu-latest' || 'self-hosted' }}
steps:
# Your build stepsStep 3: Add workflow_dispatch trigger:
on:
pull_request:
workflow_dispatch:
inputs:
runner_type:
description: 'Runner type'
required: false
type: choice
options:
- self-hosted
- github-hostedTesting
Test Decision Logic Locally
Using PowerShell:
# Test commit message detection
$commitMessage = git log -1 --pretty=%B
if ($commitMessage -match '\[github\]') {
Write-Host "Would use GitHub runner"
}
# Test PR title detection (requires gh CLI)
$prTitle = gh pr view 269 --json title -q .title
if ($prTitle -match '\[github\]') {
Write-Host "Would use GitHub runner"
}
# Test day-based default
$day = (Get-Date).Day
if ($day -ge 26) {
Write-Host "Would use GitHub runner (days 26-31)"
} else {
Write-Host "Would use self-hosted runner (days 1-25)"
}Test in GitHub Actions
Method 1: Commit message flag:
git commit --allow-empty -m "test: Check runner selection [github]"
git push
# Check workflow log for "Found [github] flag in commit"Method 2: PR title flag:
- Edit PR title, add
[github]at start - Push any commit
- Check workflow log for “Found [github] flag in PR title”
Method 3: workflow_dispatch:
- Actions tab > Android CI > Run workflow
- Select “github-hosted”
- Click “Run workflow”
- Check runner used in workflow log
Best Practices
1. Use PR Title Flags for Persistent Settings
Good:
[github] deps: Update Gradle dependencies
Why: All commits in PR use GitHub runner without repeating flag
2. Use Commit Flags for One-Time Overrides
Good:
git commit -m "fix: Emergency hotfix [github]"Why: Single commit override without affecting entire PR
3. Document Quota Usage in README
Example:
## CI Runner Configuration
- **Days 1-25:** Self-hosted runners (preserve GitHub quota)
- **Days 26-31:** GitHub-hosted runners (use remaining quota)
- **Override:** Add `[github]` or `[self-hosted]` to PR title or commit message4. Monitor Runner Availability
Add to workflow:
- name: Log runner decision
run: |
echo "Selected runner: ${{ needs.decide_runner.outputs.runner }}"
echo "Self-hosted status: [check status here]"5. Set Up Alerts for Offline Runners
Example with GitHub Actions:
- name: Check self-hosted runner status
if: needs.decide_runner.outputs.runner == 'self-hosted'
run: |
# Ping self-hosted runner
# Send alert if offlineLimitations
1. Merge Commit Message Detection
Issue: Commit message flags don’t work for merge commits in PR context
Workaround: Use PR title flags for persistent setting
Future Improvement: Parse actual commit instead of merge commit
2. API Rate Limits
Issue: gh pr view calls GitHub API
Impact: Minimal (only called once per workflow run)
Mitigation: GitHub provides generous API limits for authenticated requests
3. PowerShell Required
Issue: Decision logic uses PowerShell syntax
Alternative: Could be rewritten in Bash if needed
Rationale: PowerShell chosen for consistency with existing workflows
Related Documentation
CI/CD Guides:
- Hybrid Runner System - Overall hybrid runner architecture
- Hybrid Runner Implementation Guide - Setup instructions
- Workflows Overview - All CI workflows
Debugging Sessions:
- PR #269 Debugging Session - Where this solution was developed
External Resources:
- GitHub Actions Runners - Official GitHub docs
- GitHub Actions Workflow Syntax - Workflow YAML reference
Summary
Key Features:
- Priority-based selection - Commit > PR title > Manual > Day-based
- Always-available decision logic - Runs on
ubuntu-latest, not self-hosted - Cross-platform - Uses
pwshfor Linux/Windows compatibility - Current PR title - Fetches via API, not stale event data
- Flexible flags - Commit message, PR title, or manual input
Benefits:
- ✅ Works when self-hosted runner offline
- ✅ Flexible runtime configuration
- ✅ Automatic quota management
- ✅ No workflow file changes needed (use flags instead)
- ✅ Supports multiple override methods
Use Cases:
- Offline self-hosted runners
- Month-end quota usage
- Testing specific runner configurations
- Emergency CI runs
Status: Active - Implemented in android-ci.yml
Last Updated: 2025-11-18 Related PRs: #269 (Implementation during debugging session) Week: 28