GitHub-Hosted and Self-Hosted Runners, Labels, Groups, Scaling, and Runner Security: Guided Hands-On Workflow and Core Operations
You will first make runner identity observable without touching self-hosted infrastructure. A disposable public repository will run the same safe inspection job on two explicit GitHub-hosted Ubuntu labels. Only after that, an optional isolated private-repository exercise shows how an ephemeral self-hosted runner is registered, routed, observed, de-registered, and destroyed.
Learning objectives
- Create one no-secret workflow that targets explicit GitHub-hosted Ubuntu labels and inspect runner identity safely.
- Compare two hosted runs using versioned run/job metadata instead of assuming image/tool equivalence.
- Inspect repository self-hosted runner inventory before any registration and distinguish hosted job evidence from registered runner state.
- Understand an optional private-repository ephemeral self-hosted registration lifecycle without exposing enrollment/removal credentials.
- Use labels and an organization-group fixture to reason about routing, queueing, and least-privilege access.
Mandatory path: GitHub.com, GitHub Free, repository
owner/write access, GitHub CLI authenticated to GitHub.com, and one
disposable public personal repository. The live
mandatory jobs use only ubuntu-22.04 and
ubuntu-24.04. No self-hosted machine, secret, private
network, or paid runner is required.
Optional self-hosted path: Only use a fresh private disposable repository and a dedicated throwaway VM with no employer/VPN/internal-network access, no reusable credentials, and no sensitive data. GitHub warns against self-hosted runners for public repositories because fork PR code can potentially execute on the runner machine. Runner registration/removal tokens are credentials: obtain them locally from the current GitHub flow, never echo/paste them into the repository or lesson output, and destroy the VM after verification.
1. Preflight: create the hosted-runner observation repository
Use a fresh repository so the run history and runner evidence
contain only this chapter. Commands below are Bash/Git Bash. In
PowerShell, set equivalent variables with
$OWNER = ... / $REPO = ...; the GitHub CLI
subcommands and JSON queries are otherwise the same.
gh --version
gh auth status --active --hostname github.com
OWNER="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq .login)"
REPO="$OWNER/atlas-c16-runner-lab"
gh repo view "$REPO" --json nameWithOwner >/dev/null 2>&1 && {
echo "Repository already exists; choose a fresh lab name." >&2
exit 1
} || true
gh repo create "$REPO" --public --clone --add-readme
cd atlas-c16-runner-lab
DEFAULT_BRANCH="$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name)"
printf 'repo=%s default_branch=%s\n' "$REPO" "$DEFAULT_BRANCH"
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$REPO/actions/runners?per_page=100" \
--jq '{total_count,runners:[.runners[]|{id,name,status,busy,ephemeral}]}'
Expected runner inventory is total_count: 0. Standard
GitHub-hosted capacity is not registered as your repository’s
self-hosted runner inventory.
2. Create one workflow with an explicit hosted-runner choice
The workflow has no checkout and no GitHub API mutation.
permissions: {} removes repository token permissions
because the job only needs machine observations. The input is a
closed choice so arbitrary runs-on strings cannot be
supplied.
name: Chapter 16 hosted runner observation
on:
workflow_dispatch:
inputs:
runner:
description: Explicit GitHub-hosted runner label
required: true
type: choice
options:
- ubuntu-22.04
- ubuntu-24.04
permissions: {}
jobs:
observe:
runs-on: "${{ inputs.runner }}"
timeout-minutes: 10
steps:
- name: Selected safe runner metadata
env:
REQUESTED_RUNNER: "${{ inputs.runner }}"
run: |
printf 'requested_runner=%s\n' "$REQUESTED_RUNNER"
printf 'runner_os=%s\n' "$RUNNER_OS"
printf 'runner_arch=%s\n' "$RUNNER_ARCH"
printf 'runner_name=%s\n' "$RUNNER_NAME"
printf 'runner_environment=%s\n' "$RUNNER_ENVIRONMENT"
printf 'github_sha=%s\n' "$GITHUB_SHA"
- name: OS and tool observations
run: |
uname -a
cat /etc/os-release
printf 'cpu_count='; getconf _NPROCESSORS_ONLN
free -h
df -h "$RUNNER_TEMP"
git --version
python3 --version
Do not replace this with env, printenv, or
a whole toJSON(runner)/toJSON(github)
dump. The exercise is to select evidence, not maximize log volume.
3. Commit the workflow and prove the source revision
mkdir -p .github/workflows
# Save the YAML above as .github/workflows/ch16-hosted-observe.yml
git add .github/workflows/ch16-hosted-observe.yml
git commit -m "ci: add Chapter 16 runner observation"
git push origin "$DEFAULT_BRANCH"
WORKFLOW_SHA="$(git rev-parse HEAD)"
printf 'workflow_sha=%s\n' "$WORKFLOW_SHA"
gh workflow view ch16-hosted-observe.yml -R "$REPO" --yaml
At this point only repository content changed. No runner has started and the repository still has no self-hosted registration.
4. Dispatch the Ubuntu 22.04 run and capture its ID immediately
gh workflow run ch16-hosted-observe.yml -R "$REPO" \
--ref "$DEFAULT_BRANCH" -f runner=ubuntu-22.04
sleep 3
RUN22="$(gh run list -R "$REPO" --workflow ch16-hosted-observe.yml \
--event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$RUN22" -R "$REPO" --exit-status
printf 'run22=%s\n' "$RUN22"
Record the run ID before starting the second run so there is no ambiguity about which execution selected which label.
5. Dispatch the Ubuntu 24.04 run
gh workflow run ch16-hosted-observe.yml -R "$REPO" \
--ref "$DEFAULT_BRANCH" -f runner=ubuntu-24.04
sleep 3
RUN24="$(gh run list -R "$REPO" --workflow ch16-hosted-observe.yml \
--event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$RUN24" -R "$REPO" --exit-status
printf 'run24=%s\n' "$RUN24"
Both runs execute the same workflow revision and differ only in the
explicit runner label supplied to workflow_dispatch.
6. Inspect runner, queue, timing, and logs independently
First inspect the selected log fields, then query run/job REST
records for scheduling evidence.
created_at → run_started_at approximates workflow-run
queue/start delay; job started_at → completed_at gives
per-job execution duration. Treat tiny timing differences as
observations, not performance claims.
for RUN_ID in "$RUN22" "$RUN24"; do
echo "===== RUN $RUN_ID ====="
gh run view "$RUN_ID" -R "$REPO" --log
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$REPO/actions/runs/$RUN_ID" \
--jq '{id,event,head_sha,status,conclusion,created_at,run_started_at,updated_at,html_url}'
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$REPO/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" \
--jq '.jobs[] | {id,name,status,conclusion,runner_name,runner_group_name,labels,started_at,completed_at}'
done
Expected invariant: both runs point at WORKFLOW_SHA.
Expected difference: the job labels/logs reflect Ubuntu 22.04 versus
Ubuntu 24.04. Tool versions may differ and should be recorded rather
than predicted.
7. Prove hosted jobs did not create self-hosted runner registrations
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$REPO/actions/runners?per_page=100" \
--jq '{total_count,runners:[.runners[]|{id,name,os,status,busy,ephemeral,version,labels:[.labels[].name]}]}'
The repository self-hosted runner inventory should remain zero. This
is a useful beginner distinction: a job can have a
runner_name in its job record without that runner being
a self-hosted asset you manage.
8. Labels and groups: reason with a safe fixture before owning infrastructure
{
"runner_group": "trusted-build",
"allowed_repositories": ["octo-lab/private-app"],
"runners": [
{"name":"build-ephemeral-01","labels":["self-hosted","linux","x64","isolated-ci"],"status":"online","busy":false},
{"name":"gpu-ephemeral-01","labels":["self-hosted","linux","x64","gpu"],"status":"online","busy":false}
]
}
jobs:
compile:
runs-on:
group: trusted-build
labels: isolated-ci
model-test:
runs-on:
group: trusted-build
labels: gpu
Prediction exercise: compile can route only to a runner
in the allowed group that also has isolated-ci;
model-test requires the gpu capability. If
the repository is not allowed to access trusted-build,
labels alone cannot bypass the group boundary.
9. Optional isolated self-hosted VM: one job, then destroy it
This is optional. Create a second fresh
private repository, for example
atlas-c16-selfhosted-optional, and a dedicated
throwaway Linux VM that is not on a corporate VPN and cannot reach
production/internal networks. Patch it first. Do not use your
laptop, desktop, CI server, or a host carrying reusable credentials.
- In the private repository’s current Actions runner settings, choose the platform/architecture for the disposable VM and copy the current GitHub-provided download/configuration commands directly into that VM.
- Keep the registration token only in that local terminal. Do not save it to shell history if your environment can avoid doing so, do not print it, and do not commit it.
-
When running the configuration script, add
--ephemeraland a narrow custom label such asch16-disposable. Do not disable updates for this one-off VM. - Verify the runner appears online through the repository runner UI or the read-only REST query below.
# Run from a trusted admin workstation or the disposable VM with gh authenticated.
OPTIONAL_REPO="OWNER/atlas-c16-selfhosted-optional"
gh api -H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$OPTIONAL_REPO/actions/runners?per_page=100" \
--jq '.runners[] | {id,name,os,status,busy,ephemeral,version,labels:[.labels[].name]}'
Registration itself is security-sensitive. The lesson intentionally does not embed a real token or a timeless runner-download URL; GitHub’s UI generates platform-specific current commands and short-lived enrollment material.
10. Optional one-job validation on the disposable VM
name: Chapter 16 optional ephemeral runner
on:
workflow_dispatch:
permissions: {}
jobs:
prove_ephemeral:
runs-on: [self-hosted, ch16-disposable]
timeout-minutes: 10
steps:
- name: Print safe runner identity only
run: |
printf 'runner_name=%s\n' "$RUNNER_NAME"
printf 'runner_os=%s\n' "$RUNNER_OS"
printf 'runner_arch=%s\n' "$RUNNER_ARCH"
printf 'runner_environment=%s\n' "$RUNNER_ENVIRONMENT"
uname -a
Do not checkout repository code, load repository secrets, or attach internal/cloud credentials. After the one job, an ephemeral runner is automatically de-registered by GitHub. Verify it disappears from runner inventory, then destroy the VM. If a stale registration remains because the host was lost, remove the runner from GitHub settings or the documented admin API before reusing the hostname/machine identity.
11. Challenge: choose the control, not a copied command
For each case, choose the runner surface before writing YAML:
| Scenario | Choose | Reason |
|---|---|---|
| Public fork PR runs unit tests | Standard GitHub-hosted runner | Untrusted code should not gain your private network or persistent host residue. |
| Private repo needs a proprietary compiler and no internal network | Ephemeral self-hosted build image + narrow label | Custom toolchain with one-job lifecycle; network remains minimal. |
| Several private repos share a sensitive deployment fleet | Organization runner group + deployment capability label | Repository access boundary plus capability routing. |
| One test needs more CPU but no private network/custom toolchain | Evaluate a larger GitHub-hosted runner if plan/cost justify it | Avoid owning self-hosted infrastructure solely for machine size. |
12. Mandatory cleanup; optional runner cleanup is stricter
gh workflow disable ch16-hosted-observe.yml -R "$REPO"
gh workflow list -R "$REPO" --all --json name,path,state
gh repo archive "$REPO" --yes
gh repo view "$REPO" --json nameWithOwner,isArchived,url
For the optional self-hosted exercise: confirm no runner registration remains, preserve only sanitized logs required for the lesson, destroy the disposable VM/disk, and archive the private disposable repository. Permanent deletion is not required.
13. Lesson summary
You observed hosted runner identity and timing without whole-environment dumps, proved hosted jobs are not repository self-hosted registrations, reasoned about group+label routing, and learned the safe boundary for an optional one-job ephemeral self-hosted validation. The chapter’s central practice is inspection first: identify exact runner label, job record, registration scope, and network/lifecycle assumptions before routing valuable workloads.
Knowledge check
Why do the two hosted runs use the same workflow SHA?
Holding the source/workflow revision constant isolates the requested runner label as the intended experimental difference.
Why does the mandatory lab query repository runner inventory even though it uses hosted runners?
To prove the resource distinction: hosted job execution exists without creating self-hosted runner registrations owned by the repository.
Why is the optional self-hosted exercise private rather than public?
GitHub warns that public repository forks can potentially run dangerous PR code on self-hosted machines. A private disposable repo removes that public-fork exposure from the lab.
A runner matches every label but its group denies the repository. Can the job route there?
No. Group/repository access is an additional eligibility boundary; matching labels do not override denied group access.
After an ephemeral job, the runner disappeared from GitHub. What cleanup remains?
Destroy or wipe the underlying disposable VM/container and preserve required runner logs externally. De-registration alone does not erase the host filesystem.
Further reading — current official GitHub sources
- GitHub Docs — GitHub-hosted runners reference
- GitHub Docs — Self-hosted runners concepts
- GitHub Docs — Self-hosted runners reference
- GitHub Docs — Adding self-hosted runners
- GitHub Docs — Using self-hosted runners in a workflow
- GitHub Docs — Using labels with self-hosted runners
- GitHub Docs — Managing self-hosted runner access with groups
- GitHub Docs — Larger runners
- GitHub Docs — Choosing the runner for a job
- GitHub REST — Self-hosted runners
- GitHub REST — Workflow jobs
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.