Hybrid GitHub Actions Runner Implementation Guide
Date: 2025-10-23
Purpose: Add intelligent runner selection to manage GitHub Actions minutes quota (3000/month)
Overview
This implementation adds hybrid runner support to Android CI/CD workflows:
- Defaults to GitHub-hosted runners (
ubuntu-latest) - Honors commit message flags:
[self-hosted],[github],[skip-ci] - Automatically switches to self-hosted after day 20 of month
- Supports manual workflow dispatch override
- Splits jobs: fast checks on GitHub, heavy work on selected runner
Implementation Summary
Changes Made
-
android-ci.yml
- Added
workflow_dispatchtrigger with runner selection - Added
decide_runnerjob for runner selection logic - Split into 3 jobs:
quick_checks,test_and_coverage,build_artifacts - Preserved all caching, artifacts, Codecov integration
- Added
-
deploy-to-play-store.yml
- Added
workflow_dispatchtrigger with runner selection - Added
decide_runnerjob - Renamed
deploy→build_and_deploywith runner selection - Preserved all keystore, signing, deployment steps
- Added
-
documentation-reminder.yml
- No changes (lightweight, stays GitHub-hosted)
android-ci.yml - Complete Updated File
Due to file writing issues in the tool, here are the key sections to add/modify:
Add to on: trigger section:
on:
pull_request:
branches: [main]
paths-ignore:
# ... existing paths-ignore ...
workflow_dispatch: # ADD THIS
inputs:
runner_mode:
description: 'Runner selection'
required: false
default: 'auto'
type: choice
options:
- auto
- self-hosted
- github
- skipAdd new decide_runner job (before all other jobs):
jobs:
decide_runner:
runs-on: ubuntu-latest
outputs:
runner_label: ${{ steps.decision.outputs.runner_label }}
should_skip: ${{ steps.decision.outputs.should_skip }}
steps:
- name: Determine runner
id: decision
run: |
COMMIT_MSG="${{ github.event.head_commit.message || github.event.pull_request.title }}"
INPUT_MODE="${{ github.event.inputs.runner_mode || 'auto' }}"
RUNNER="ubuntu-latest"
SKIP="false"
# Check skip flags
if [[ "$COMMIT_MSG" =~ \[skip-ci\]|\[ci-skip\] ]] || [[ "$INPUT_MODE" == "skip" ]]; then
echo "should_skip=true" >> $GITHUB_OUTPUT
echo "runner_label=ubuntu-latest" >> $GITHUB_OUTPUT
echo "::notice::CI skipped due to [skip-ci] flag"
exit 0
fi
# Explicit overrides
if [[ "$COMMIT_MSG" =~ \[self-hosted\] ]] || [[ "$INPUT_MODE" == "self-hosted" ]]; then
RUNNER="self-hosted"
echo "::notice::Using self-hosted runner (explicit override)"
elif [[ "$COMMIT_MSG" =~ \[github\] ]] || [[ "$INPUT_MODE" == "github" ]]; then
RUNNER="ubuntu-latest"
echo "::notice::Using GitHub-hosted runner (explicit override)"
elif [[ "$INPUT_MODE" == "auto" ]]; then
# Auto mode: Use day-of-month heuristic
DAY=$(date +%d)
if [ "$DAY" -gt 20 ]; then
echo "::notice::Late in month (day $DAY), using self-hosted to conserve quota"
RUNNER="self-hosted"
else
echo "::notice::Early in month (day $DAY), using GitHub-hosted"
RUNNER="ubuntu-latest"
fi
fi
echo "should_skip=false" >> $GITHUB_OUTPUT
echo "runner_label=$RUNNER" >> $GITHUB_OUTPUT
echo "::notice::Selected runner: $RUNNER"Replace build-and-test job with 3 jobs:
1. quick_checks (always GitHub-hosted):
quick_checks:
runs-on: ubuntu-latest
needs: decide_runner
if: needs.decide_runner.outputs.should_skip == 'false'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Generate google-services.json
run: |
echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > app/google-services.json
- name: Verify google-services.json
run: |
# ... existing verification script ...
- name: Run lint
run: ./gradlew lint
continue-on-error: true
- name: Upload lint reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-reports
path: |
app/build/reports/lint-results*.html
app/build/reports/lint-results*.xml
retention-days: 72. test_and_coverage (selected runner):
test_and_coverage:
runs-on: ${{ needs.decide_runner.outputs.runner_label }}
needs: [decide_runner, quick_checks]
if: needs.decide_runner.outputs.should_skip == 'false'
steps:
# ... all existing test/coverage steps from original build-and-test ...
# Include: Checkout, JDK setup, caching, Robolectric, coverage, Codecov3. build_artifacts (selected runner, PR only):
build_artifacts:
runs-on: ${{ needs.decide_runner.outputs.runner_label }}
needs: [decide_runner, quick_checks]
if: |
needs.decide_runner.outputs.should_skip == 'false' &&
github.event_name == 'pull_request'
steps:
# ... all existing build steps from original build-and-test ...
# Include: assembleDebug, assembleRelease, testDebugUnitTestdeploy-to-play-store.yml - Complete Updated File
Add to on: section:
on:
push:
branches: [ main ]
workflow_dispatch:
inputs: # MODIFY THIS - add runner_mode
runner_mode:
description: 'Runner selection'
required: false
default: 'auto'
type: choice
options:
- auto
- self-hosted
- github
- skipAdd decide_runner job (same as android-ci.yml):
jobs:
decide_runner:
runs-on: ubuntu-latest
outputs:
runner_label: ${{ steps.decision.outputs.runner_label }}
should_skip: ${{ steps.decision.outputs.should_skip }}
steps:
# ... same decision logic as android-ci.yml ...Rename deploy job to build_and_deploy with runner selection:
build_and_deploy: # RENAMED from 'deploy'
runs-on: ${{ needs.decide_runner.outputs.runner_label }} # CHANGED
needs: decide_runner # ADDED
if: needs.decide_runner.outputs.should_skip == 'false' # ADDED
steps:
# ... ALL existing deployment steps unchanged ...Verification Checklist
android-ci.yml
-
workflow_dispatchtrigger added withrunner_modeinput -
decide_runnerjob added (always ubuntu-latest) -
quick_checksjob created (always ubuntu-latest) -
test_and_coveragejob created (selected runner) -
build_artifactsjob created (selected runner, PR only) - Job dependencies correct:
needs: [decide_runner, quick_checks] - All caching preserved (Android build cache, Robolectric)
- Codecov
fail_ci_if_error: truepreserved - All artifact uploads preserved (coverage, test reports, lint)
- All environment variables preserved
- All
continue-on-errorflags preserved
deploy-to-play-store.yml
-
workflow_dispatchinputs updated withrunner_mode -
decide_runnerjob added -
deployjob renamed tobuild_and_deploy - Runner selection applied:
runs-on: ${{ needs.decide_runner.outputs.runner_label }} -
needs: decide_runnerdependency added - Skip check added:
if: needs.decide_runner.outputs.should_skip == 'false' - All keystore/signing steps preserved
- All deployment steps preserved
- Discord notification preserved
Usage Examples
Commit Message Flags
# Force self-hosted runner
git commit -m "feat: add new feature [self-hosted]"
# Force GitHub-hosted runner
git commit -m "fix: quick bugfix [github]"
# Skip CI entirely
git commit -m "docs: update README [skip-ci]"Manual Workflow Dispatch
- Go to Actions tab → Select workflow
- Click “Run workflow”
- Select runner_mode:
auto: Day > 20 = self-hosted, else GitHubself-hosted: Force self-hostedgithub: Force GitHub-hostedskip: Skip workflow
Auto Mode Behavior
- Day 1-20: Uses
ubuntu-latest(GitHub-hosted) - Day 21-31: Uses
self-hostedto conserve quota - Monthly quota: 3000 minutes
Preserved Functionality
✅ All existing caching strategies ✅ Robolectric dependency management ✅ JaCoCo coverage reports ✅ Codecov integration with strict enforcement ✅ Artifact uploads (test reports, coverage, lint) ✅ Environment variables (CI=true, GRADLE_OPTS, JAVA_OPTS) ✅ Continue-on-error flags ✅ Retention policies (2-7 days) ✅ All if conditions and failure handling ✅ Instrumented test configuration (commented out) ✅ Discord notifications (deployment) ✅ Play Store deployment flow
Notes
- Self-hosted runner setup required before using
self-hostedmode - Testing: Start with
[github]flag to verify changes work - Quota monitoring: Check usage at Settings → Billing → Actions minutes
- Fallback: Always defaults to
ubuntu-latestif decision logic fails
Status: Implementation guide complete Next Steps: Apply changes to workflow files manually