Cross-Platform Self-Hosted Runner Patterns
Implemented: 2025-11-27 Status: Active - Production fix for GitHub billing blocks Purpose: Enable CI workflows to run on any self-hosted runner platform (Windows, macOS, Linux)
Overview
This guide documents the cross-platform patterns used when self-hosted runners may run on different operating systems. These patterns were developed during the BILLING_FIX_2025_11_27 incident when GitHub billing issues blocked all GitHub-hosted runners.
Key Insight: Self-hosted runners have no quota limits and aren’t affected by GitHub billing/payment issues, making them the reliable fallback when GitHub-hosted runners become unavailable.
The Problem
When GitHub billing issues occur (payment processing failures, quota exceeded, account blocks), all GitHub-hosted runners become unavailable:
Error: The job was not started because recent account payments have failed
The solution is to default to self-hosted runners, but self-hosted runners can be on different platforms (Windows desktop, macOS laptop, Linux server). Workflows must handle all platforms gracefully.
Architecture
Platform Detection
GitHub Actions provides runner.os to detect the current platform:
runner.os | Platform |
|---|---|
Windows | Windows 10/11, Windows Server |
macOS | macOS (Intel or Apple Silicon) |
Linux | Ubuntu, Debian, RHEL, etc. |
Conditional Step Execution
Use if: conditions to run platform-specific steps:
# Windows-only step
- name: Do something (Windows)
if: runner.os == 'Windows'
shell: powershell
run: |
# PowerShell commands here
# Non-Windows (macOS/Linux) step
- name: Do something (macOS/Linux)
if: runner.os != 'Windows'
shell: bash
run: |
# Bash commands hereCritical Patterns
1. Output Merging for Runner Decision
When the decide_runner job may run on any platform, both Windows and Unix steps must set the same outputs. Use the || operator to merge outputs:
decide_runner:
runs-on: self-hosted
outputs:
# Use outputs from whichever platform-specific step ran
runner_label: ${{ steps.decision.outputs.runner_label || steps.decision_unix.outputs.runner_label }}
should_skip: ${{ steps.decision.outputs.should_skip || steps.decision_unix.outputs.should_skip }}
steps:
# Windows runner - use PowerShell
- name: Determine runner (Windows)
id: decision
if: runner.os == 'Windows'
shell: powershell
run: |
# ... PowerShell logic ...
"runner_label=$RUNNER" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"should_skip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# macOS/Linux runner - use bash
- name: Determine runner (macOS/Linux)
id: decision_unix
if: runner.os != 'Windows'
shell: bash
run: |
# ... Bash logic ...
echo "runner_label=$RUNNER" >> $GITHUB_OUTPUT
echo "should_skip=false" >> $GITHUB_OUTPUTWhy This Works:
- Only one platform step executes per run
- The
||operator returns the first truthy value - If Windows step runs,
steps.decision.outputs.*is set - If Unix step runs,
steps.decision_unix.outputs.*is set - Either way, the job output is populated
2. PowerShell GITHUB_OUTPUT Syntax
PowerShell uses different syntax for writing to GITHUB_OUTPUT:
# PowerShell - use Out-File with -Append
"variable_name=value" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
# Multiple outputs
"runner_label=$RUNNER" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"should_skip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append# Bash - use >> redirection
echo "variable_name=value" >> $GITHUB_OUTPUT
# Multiple outputs
echo "runner_label=$RUNNER" >> $GITHUB_OUTPUT
echo "should_skip=false" >> $GITHUB_OUTPUT3. PowerShell Error Handling for Git Commands
Git commands may fail when refs don’t exist. PowerShell requires explicit error handling:
$ErrorActionPreference = "SilentlyContinue"
$PR_HEAD_SHA = "${{ github.event.pull_request.head.sha }}"
$COMMIT_MSG = ""
if ($PR_HEAD_SHA) {
# Try to get commit message from PR head, fall back to HEAD
try {
$COMMIT_MSG = git log -1 --pretty=%B $PR_HEAD_SHA 2>&1
if ($LASTEXITCODE -ne 0) { $COMMIT_MSG = "" }
} catch { $COMMIT_MSG = "" }
if (-not $COMMIT_MSG) {
$COMMIT_MSG = git log -1 --pretty=%B HEAD
}
} else {
$COMMIT_MSG = git log -1 --pretty=%B HEAD
}
$ErrorActionPreference = "Stop"Key Points:
- Set
$ErrorActionPreference = "SilentlyContinue"before git commands - Check
$LASTEXITCODEafter git commands - Use
try/catchfor additional safety - Reset to
$ErrorActionPreference = "Stop"after error-prone section
# Bash equivalent - simpler with || fallback
COMMIT_MSG=$(git log -1 --pretty=%B "$PR_HEAD_SHA" 2>/dev/null || git log -1 --pretty=%B HEAD)4. BSD sed Compatibility (macOS)
macOS uses BSD sed which requires -i.bak instead of -i:
# Works on both Linux (GNU sed) and macOS (BSD sed)
sed -i.bak "s/old_pattern/new_pattern/" file.txt
rm -f file.txt.bak
# DON'T do this - fails on macOS
sed -i "s/old_pattern/new_pattern/" file.txt # ERROR on macOSExample from deploy workflow:
- name: Update build.gradle.kts (Linux/macOS)
if: runner.os != 'Windows'
run: |
# Portable sed -i for both Linux and macOS (create backup, then remove it)
sed -i.bak "s/versionCode = [0-9][0-9]*/versionCode = $NEW_VERSION_CODE/" app/build.gradle.kts
rm -f app/build.gradle.kts.bak5. Windows File Operations with PowerShell
Use PowerShell cmdlets for file operations on Windows:
# Write content to file
'content' | Out-File -FilePath path/to/file -Encoding UTF8 -NoNewline
# Read and modify content
(Get-Content file.txt) -replace 'old', 'new' | Set-Content file.txt
# Delete file
Remove-Item -Path file.txt -ErrorAction SilentlyContinue# Bash equivalents
printf '%s' 'content' > path/to/file
# or
echo 'content' > path/to/filePlatform Compatibility Matrix
| Operation | Windows (PowerShell) | macOS/Linux (Bash) |
|---|---|---|
| Set output | "key=value" | Out-File -FilePath $env:GITHUB_OUTPUT -Append | echo "key=value" >> $GITHUB_OUTPUT |
| Write file | 'content' | Out-File -FilePath file -Encoding UTF8 | printf '%s' 'content' > file |
| In-place sed | N/A (use -replace) | sed -i.bak 's/old/new/' file && rm file.bak |
| Regex replace | (Get-Content f) -replace 'old','new' | Set-Content f | sed -i.bak 's/old/new/' f && rm f.bak |
| Delete file | Remove-Item -Path f -ErrorAction SilentlyContinue | rm -f f |
| Suppress errors | $ErrorActionPreference = "SilentlyContinue" | 2>/dev/null or || true |
| Check exit code | $LASTEXITCODE | $? |
| Run Gradle | .\gradlew.bat task | ./gradlew task |
Complete Example: decide_runner Job
This is the canonical pattern for a runner decision job that works on any platform:
jobs:
# BILLING_FIX_2025_11_27: Run on self-hosted to avoid GitHub billing blocks
decide_runner:
runs-on: self-hosted
outputs:
# Use outputs from whichever platform-specific step ran
runner_label: ${{ steps.decision.outputs.runner_label || steps.decision_unix.outputs.runner_label }}
should_skip: ${{ steps.decision.outputs.should_skip || steps.decision_unix.outputs.should_skip }}
steps:
- name: Checkout code
uses: actions/checkout@v5
# Windows runner - use PowerShell
- name: Determine runner (Windows)
id: decision
if: runner.os == 'Windows'
shell: powershell
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = "SilentlyContinue"
$PR_HEAD_SHA = "${{ github.event.pull_request.head.sha }}"
$COMMIT_MSG = ""
if ($PR_HEAD_SHA) {
try {
$COMMIT_MSG = git log -1 --pretty=%B $PR_HEAD_SHA 2>&1
if ($LASTEXITCODE -ne 0) { $COMMIT_MSG = "" }
} catch { $COMMIT_MSG = "" }
if (-not $COMMIT_MSG) {
$COMMIT_MSG = git log -1 --pretty=%B HEAD
}
} else {
$COMMIT_MSG = git log -1 --pretty=%B HEAD
}
$ErrorActionPreference = "Stop"
$INPUT_MODE = "${{ github.event.inputs.runner_mode || 'auto' }}"
$RUNNER = "self-hosted"
Write-Host "::notice::Commit message: $COMMIT_MSG"
Write-Host "::notice::Input mode: $INPUT_MODE"
if ($COMMIT_MSG -match '\[skip-ci\]|\[ci-skip\]' -or $INPUT_MODE -eq "skip") {
"should_skip=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"runner_label=self-hosted" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "::notice::CI skipped due to [skip-ci] flag"
exit 0
}
if ($COMMIT_MSG -match '\[self-hosted\]') { $RUNNER = "self-hosted" }
elseif ($COMMIT_MSG -match '\[github\]') { $RUNNER = "ubuntu-latest" }
elseif ($INPUT_MODE -eq "self-hosted") { $RUNNER = "self-hosted" }
elseif ($INPUT_MODE -eq "github") { $RUNNER = "ubuntu-latest" }
else { $RUNNER = "self-hosted" }
"should_skip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"runner_label=$RUNNER" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "::notice::Selected runner: $RUNNER"
# macOS/Linux runner - use bash
- name: Determine runner (macOS/Linux)
id: decision_unix
if: runner.os != 'Windows'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_HEAD_SHA="${{ github.event.pull_request.head.sha }}"
if [[ -n "$PR_HEAD_SHA" ]]; then
COMMIT_MSG=$(git log -1 --pretty=%B "$PR_HEAD_SHA" 2>/dev/null || git log -1 --pretty=%B HEAD)
else
COMMIT_MSG=$(git log -1 --pretty=%B HEAD)
fi
INPUT_MODE="${{ github.event.inputs.runner_mode || 'auto' }}"
RUNNER="self-hosted"
echo "::notice::Commit message: $COMMIT_MSG"
echo "::notice::Input mode: $INPUT_MODE"
if [[ "$COMMIT_MSG" =~ \[skip-ci\]|\[ci-skip\] ]] || [[ "$INPUT_MODE" == "skip" ]]; then
echo "should_skip=true" >> $GITHUB_OUTPUT
echo "runner_label=self-hosted" >> $GITHUB_OUTPUT
echo "::notice::CI skipped due to [skip-ci] flag"
exit 0
fi
if [[ "$COMMIT_MSG" =~ \[self-hosted\] ]]; then RUNNER="self-hosted"
elif [[ "$COMMIT_MSG" =~ \[github\] ]]; then RUNNER="ubuntu-latest"
elif [[ "$INPUT_MODE" == "self-hosted" ]]; then RUNNER="self-hosted"
elif [[ "$INPUT_MODE" == "github" ]]; then RUNNER="ubuntu-latest"
else RUNNER="self-hosted"
fi
echo "should_skip=false" >> $GITHUB_OUTPUT
echo "runner_label=$RUNNER" >> $GITHUB_OUTPUT
echo "::notice::Selected runner: $RUNNER"
# Subsequent jobs use the selected runner
build:
runs-on: ${{ needs.decide_runner.outputs.runner_label }}
needs: decide_runner
if: needs.decide_runner.outputs.should_skip != 'true'
steps:
# Platform-specific steps as needed...Affected Workflows
The following workflows use cross-platform patterns:
| Workflow | Repository | Location |
|---|---|---|
| Android CI | archery-apprentice | .github/workflows/android-ci.yml |
| Non-Android Check | archery-apprentice | .github/workflows/non-android-check.yml |
| Deploy to Play Store | archery-apprentice | .github/workflows/deploy-to-play-store.yml |
| Deploy Quartz | archery-apprentice-docs | .github/workflows/deploy-quartz.yml |
Troubleshooting
Output Not Found
Symptom: Downstream job fails with “runner_label output not found”
Cause: Platform step didn’t run or output syntax was wrong
Solution:
- Verify
if:condition matches actual runner OS - Check output syntax (PowerShell vs Bash)
- Verify both steps have unique
id:values - Check
outputs:section uses correct||merge syntax
Git Command Fails on Windows
Symptom: PowerShell errors with git commands
Solution: Use proper error handling:
$ErrorActionPreference = "SilentlyContinue"
# git commands here
$ErrorActionPreference = "Stop"sed Fails on macOS
Symptom: sed: -i may not be used with stdin
Solution: Use BSD-compatible syntax:
sed -i.bak 's/old/new/' file && rm file.bakRelated Documentation
- Hybrid Runner System - Original runner setup and quota management
- CD Troubleshooting Guide - General troubleshooting
- PowerShell Emoji Encoding - Discord notification patterns
Tags: ci-cd cross-platform github-actions billing-fix infrastructure Status: Active in production Incident Reference: BILLING_FIX_2025_11_27