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:

  1. Workflows hardcoded to specific runner type
  2. Can’t change workflow without new commit
  3. Self-hosted runner offline → can’t run decision logic
  4. Need flexible, runtime-configurable runner selection

The Solution

Priority-based runner selection system:

  1. Priority 1: Commit message flags ([github], [self-hosted], [skip-ci])
  2. Priority 2: PR title flags (persistent, API-fetched)
  3. Priority 3: Manual workflow_dispatch input
  4. 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 steps

Key Points:

  • decide_runner job runs on ubuntu-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:

  1. Go to Actions tab in GitHub
  2. Select “Android CI” workflow
  3. Click “Run workflow”
  4. Select runner type from dropdown
  5. 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 available

Result: 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 commands

Error:

/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 commands

Result: 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.title cached 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:

  1. 00:00 - Push commit, workflow triggers
  2. 00:01 - Realize need GitHub runner
  3. 00:02 - Add [github] to PR title
  4. 00:03 - Workflow checks title → sees old title (no [github])
  5. 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 title

Result: 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 -1 shows 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:

  1. Try commit message first (might be merge commit)
  2. If no flag in commit, check PR title
  3. 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 1

Result: 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 section

Step 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 steps

Step 3: Add workflow_dispatch trigger:

on:
  pull_request:
  workflow_dispatch:
    inputs:
      runner_type:
        description: 'Runner type'
        required: false
        type: choice
        options:
          - self-hosted
          - github-hosted

Testing

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 message

4. 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 offline

Limitations

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


CI/CD Guides:

Debugging Sessions:

External Resources:


Summary

Key Features:

  1. Priority-based selection - Commit > PR title > Manual > Day-based
  2. Always-available decision logic - Runs on ubuntu-latest, not self-hosted
  3. Cross-platform - Uses pwsh for Linux/Windows compatibility
  4. Current PR title - Fetches via API, not stale event data
  5. 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