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.osPlatform
WindowsWindows 10/11, Windows Server
macOSmacOS (Intel or Apple Silicon)
LinuxUbuntu, 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 here

Critical 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_OUTPUT

Why 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_OUTPUT

3. 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 $LASTEXITCODE after git commands
  • Use try/catch for 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 macOS

Example 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.bak

5. 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/file

Platform Compatibility Matrix

OperationWindows (PowerShell)macOS/Linux (Bash)
Set output"key=value" | Out-File -FilePath $env:GITHUB_OUTPUT -Appendecho "key=value" >> $GITHUB_OUTPUT
Write file'content' | Out-File -FilePath file -Encoding UTF8printf '%s' 'content' > file
In-place sedN/A (use -replace)sed -i.bak 's/old/new/' file && rm file.bak
Regex replace(Get-Content f) -replace 'old','new' | Set-Content fsed -i.bak 's/old/new/' f && rm f.bak
Delete fileRemove-Item -Path f -ErrorAction SilentlyContinuerm -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:

WorkflowRepositoryLocation
Android CIarchery-apprentice.github/workflows/android-ci.yml
Non-Android Checkarchery-apprentice.github/workflows/non-android-check.yml
Deploy to Play Storearchery-apprentice.github/workflows/deploy-to-play-store.yml
Deploy Quartzarchery-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:

  1. Verify if: condition matches actual runner OS
  2. Check output syntax (PowerShell vs Bash)
  3. Verify both steps have unique id: values
  4. 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.bak


Tags: ci-cd cross-platform github-actions billing-fix infrastructure Status: Active in production Incident Reference: BILLING_FIX_2025_11_27