id: github-actions-runner-disk-cache-pruner
namespace: company.devops
description: |
Polls GitHub Actions runner fleet status via GitHub API, checks runner disk
utilization via SSH, prunes Docker caches and temporary build directories when usage
exceeds 85%, and reports reclaimed capacity to Kestra KV and Slack.
inputs:
- id: github_organization
type: STRING
displayName: GitHub Organization
defaults: my-enterprise-org
description: GitHub organization hosting the self-hosted runner fleet.
- id: runner_group
type: STRING
displayName: Runner Group
defaults: default
description: Target runner group name to inspect (or 'all' for organization-wide fleet).
- id: runner_name
type: STRING
displayName: Target Runner Name
defaults: linux-x64-runner-01
description: Registered self-hosted runner name to inspect and prune.
- id: runner_host
type: STRING
displayName: Runner Host or IP
defaults: runner-node-01.internal.corp
description: Reachable hostname or IP address of the self-hosted runner server.
- id: runner_port
type: INT
displayName: SSH Port
defaults: 22
description: SSH connection port for remote runner maintenance.
- id: runner_user
type: STRING
displayName: SSH Username
defaults: runner
description: Remote Linux user account with sudo and Docker access.
- id: disk_threshold_pct
type: INT
displayName: Disk Threshold (%)
defaults: 85
description: Disk utilization threshold percentage. Pruning triggers when usage
meets or exceeds this value.
- id: temp_directory
type: STRING
displayName: Temp Directory Path
defaults: /home/runner/_work/_temp
description: Absolute path to the runner temporary build and cache directory to purge.
- id: dry_run
type: BOOL
displayName: Dry Run Mode
defaults: false
description: When true, simulates disk pruning commands without deleting Docker
images, volumes, or files.
- id: force_when_busy
type: BOOL
displayName: Force When Busy
defaults: false
description: Override safety guard to allow pruning even if the runner is
currently executing a job.
triggers:
- id: scheduled_runner_maintenance
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 */4 * * *"
timezone: UTC
disabled: true
description: Regular schedule to audit runner fleet disk usage every 4 hours.
tasks:
- id: poll_github_runners
type: io.kestra.plugin.scripts.python.Script
description: Query GitHub Actions REST API for organization runners and verify
target runner availability.
containerImage: python:3.11-slim
beforeCommands:
- pip install requests kestra
env:
GITHUB_TOKEN: "{{ secret('GITHUB_TOKEN') }}"
script: |
import os
import requests
from kestra import Kestra
org = "{{ inputs.github_organization }}"
target_name = "{{ inputs.runner_name }}"
target_group_filter = "{{ inputs.runner_group }}"
token = os.environ.get("GITHUB_TOKEN")
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
}
if token:
headers["Authorization"] = f"Bearer {token}"
group_map = {}
try:
gr = requests.get(f"https://api.github.com/orgs/{org}/actions/runner-groups", headers=headers, timeout=20)
if gr.status_code == 200:
for g in gr.json().get("runner_groups", []):
group_map[g["id"]] = g.get("name", "unknown")
except Exception as err:
print(f"Notice: unable to query runner groups: {err}")
url = f"https://api.github.com/orgs/{org}/actions/runners"
response = requests.get(url, headers=headers, timeout=25)
if response.status_code != 200:
print(f"GitHub API Error {response.status_code}: {response.text}")
response.raise_for_status()
data = response.json()
runners = data.get("runners", [])
total_runners = len(runners)
online_runners = sum(1 for r in runners if r.get("status") == "online")
offline_runners = sum(1 for r in runners if r.get("status") == "offline")
busy_runners = sum(1 for r in runners if r.get("busy") is True)
target = next((r for r in runners if r.get("name") == target_name), None)
if target:
target_id = target.get("id")
target_status = target.get("status", "unknown")
target_busy = target.get("busy", False)
target_group = group_map.get(target.get("runner_group_id"), "default")
print(f"Target runner '{target_name}' (ID: {target_id}) found: status={target_status}, busy={target_busy}, group={target_group}")
else:
target_id = None
target_status = "NOT_FOUND"
target_busy = False
target_group = "unknown"
print(f"Target runner '{target_name}' was not found in organization '{org}' fleet.")
Kestra.outputs({
"total_runners": total_runners,
"online_runners": online_runners,
"offline_runners": offline_runners,
"busy_runners": busy_runners,
"target_runner_found": target is not None,
"target_runner_id": target_id,
"target_runner_status": target_status,
"target_runner_busy": target_busy,
"target_runner_group": target_group
})
- id: verify_runner_online
type: io.kestra.plugin.core.flow.If
description: Halt execution if the target runner is not in an online state
according to GitHub Actions API.
condition: "{{ outputs.poll_github_runners.vars.target_runner_status != 'online' }}"
then:
- id: fail_runner_not_online
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Target runner '{{ inputs.runner_name }}' is not online (status:
{{ outputs.poll_github_runners.vars.target_runner_status }}). Halting
disk maintenance."
- id: check_runner_disk_pre
type: io.kestra.plugin.fs.ssh.Command
description: Connect via SSH to runner host and measure initial disk utilization.
host: "{{ inputs.runner_host }}"
port: "{{ inputs.runner_port }}"
username: "{{ inputs.runner_user }}"
privateKey: "{{ secret('RUNNER_SSH_PRIVATE_KEY') }}"
commands:
- |
DISK_LINE=$(df -Pk / | awk 'NR==2')
TOTAL_KB=$(echo "$DISK_LINE" | awk '{print $2}')
USED_KB=$(echo "$DISK_LINE" | awk '{print $3}')
AVAIL_KB=$(echo "$DISK_LINE" | awk '{print $4}')
USAGE_PCT=$(echo "$DISK_LINE" | awk '{print $5}' | tr -d '%')
TOTAL_GB=$(awk "BEGIN {printf \"%.2f\", $TOTAL_KB/1048576}")
USED_GB=$(awk "BEGIN {printf \"%.2f\", $USED_KB/1048576}")
AVAIL_GB=$(awk "BEGIN {printf \"%.2f\", $AVAIL_KB/1048576}")
echo "Initial disk inspection: ${USAGE_PCT}% used (${USED_GB} GB of ${TOTAL_GB} GB, ${AVAIL_GB} GB free)"
echo "::{\"outputs\":{\"disk_used_pct\":$USAGE_PCT,\"total_gb\":$TOTAL_GB,\"used_gb\":$USED_GB,\"avail_gb\":$AVAIL_GB}}::"
- id: evaluate_disk_threshold
type: io.kestra.plugin.core.flow.If
description: Trigger prune operations when runner root filesystem usage meets or
exceeds threshold.
condition: "{{ (outputs.check_runner_disk_pre.vars.disk_used_pct | int) >=
(inputs.disk_threshold_pct | int) }}"
then:
- id: guard_active_job
type: io.kestra.plugin.core.flow.If
description: Safeguard against pruning caches while the runner is actively
executing a GitHub Actions job.
condition: "{{ outputs.poll_github_runners.vars.target_runner_busy == true and
not inputs.force_when_busy }}"
then:
- id: abort_on_busy_runner
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Target runner '{{ inputs.runner_name }}' is currently busy
executing a workflow job. Prune aborted to prevent build
corruption."
- id: log_prune_initiated
type: io.kestra.plugin.core.log.Log
message: "Runner '{{ inputs.runner_name }}' disk usage is {{
outputs.check_runner_disk_pre.vars.disk_used_pct }}% (threshold {{
inputs.disk_threshold_pct }}%). Starting prune actions."
- id: prune_docker_and_temp_cache
type: io.kestra.plugin.fs.ssh.Command
description: Prune unused Docker system containers, images, volumes, and purge
temporary build cache directory.
host: "{{ inputs.runner_host }}"
port: "{{ inputs.runner_port }}"
username: "{{ inputs.runner_user }}"
privateKey: "{{ secret('RUNNER_SSH_PRIVATE_KEY') }}"
commands:
- |
if [ "{{ inputs.dry_run }}" = "true" ]; then
echo "[DRY RUN] Simulating prune actions. No files or images will be modified."
echo "[DRY RUN] Target command 1: docker system prune -af --volumes"
echo "[DRY RUN] Target command 2: rm -rf {{ inputs.temp_directory }}/*"
else
echo "Pruning unused Docker images, containers, and dangling volumes..."
docker system prune -af --volumes
echo "Purging stale runner temp build files in {{ inputs.temp_directory }}..."
if [ -d "{{ inputs.temp_directory }}" ]; then
rm -rf {{ inputs.temp_directory }}/* 2>/dev/null || true
fi
echo "Runner disk prune actions completed."
fi
- id: check_runner_disk_post
type: io.kestra.plugin.fs.ssh.Command
description: Measure runner disk utilization following prune operations to
determine reclaimed storage.
host: "{{ inputs.runner_host }}"
port: "{{ inputs.runner_port }}"
username: "{{ inputs.runner_user }}"
privateKey: "{{ secret('RUNNER_SSH_PRIVATE_KEY') }}"
commands:
- |
DISK_LINE=$(df -Pk / | awk 'NR==2')
TOTAL_KB=$(echo "$DISK_LINE" | awk '{print $2}')
USED_KB=$(echo "$DISK_LINE" | awk '{print $3}')
AVAIL_KB=$(echo "$DISK_LINE" | awk '{print $4}')
USAGE_PCT=$(echo "$DISK_LINE" | awk '{print $5}' | tr -d '%')
TOTAL_GB=$(awk "BEGIN {printf \"%.2f\", $TOTAL_KB/1048576}")
USED_GB=$(awk "BEGIN {printf \"%.2f\", $USED_KB/1048576}")
AVAIL_GB=$(awk "BEGIN {printf \"%.2f\", $AVAIL_KB/1048576}")
echo "Post-prune disk inspection: ${USAGE_PCT}% used (${USED_GB} GB of ${TOTAL_GB} GB, ${AVAIL_GB} GB free)"
echo "::{\"outputs\":{\"disk_used_pct\":$USAGE_PCT,\"total_gb\":$TOTAL_GB,\"used_gb\":$USED_GB,\"avail_gb\":$AVAIL_GB}}::"
- id: calculate_reclaimed_metrics
type: io.kestra.plugin.scripts.python.Script
description: Compute delta metrics for reclaimed disk capacity and percentage
recovery.
containerImage: python:3.11-slim
beforeCommands:
- pip install kestra
env:
PRE_USED_GB: "{{ outputs.check_runner_disk_pre.vars.used_gb }}"
POST_USED_GB: "{{ outputs.check_runner_disk_post.vars.used_gb }}"
PRE_PCT: "{{ outputs.check_runner_disk_pre.vars.disk_used_pct }}"
POST_PCT: "{{ outputs.check_runner_disk_post.vars.disk_used_pct }}"
DRY_RUN: "{{ inputs.dry_run }}"
script: |
import os
from kestra import Kestra
pre_used = float(os.environ.get("PRE_USED_GB", 0))
post_used = float(os.environ.get("POST_USED_GB", 0))
pre_pct = int(os.environ.get("PRE_PCT", 0))
post_pct = int(os.environ.get("POST_PCT", 0))
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
reclaimed_gb = max(0.0, round(pre_used - post_used, 2))
pct_recovered = max(0, pre_pct - post_pct)
action_taken = "SIMULATED_PRUNED" if dry_run else "PRUNED"
print(f"Action: {action_taken}, Reclaimed: {reclaimed_gb} GB, Recycled: {pct_recovered}%")
Kestra.outputs({
"action_taken": action_taken,
"reclaimed_gb": reclaimed_gb,
"pct_recovered": pct_recovered,
"final_used_pct": post_pct,
"prune_executed": True
})
else:
- id: log_disk_healthy
type: io.kestra.plugin.core.log.Log
message: "Runner '{{ inputs.runner_name }}' disk usage is {{
outputs.check_runner_disk_pre.vars.disk_used_pct }}%, below {{
inputs.disk_threshold_pct }}% threshold. No prune required."
- id: record_healthy_metrics
type: io.kestra.plugin.core.output.OutputValues
description: Record baseline metrics when disk capacity is within safe parameters.
values:
action_taken: "SKIPPED_HEALTHY"
reclaimed_gb: 0.0
pct_recovered: 0
final_used_pct: "{{ outputs.check_runner_disk_pre.vars.disk_used_pct }}"
prune_executed: false
- id: store_prune_metrics_kv
type: io.kestra.plugin.core.kv.Set
description: Store runner disk utilization and prune audit record in Kestra KV store.
key: "runner_maintenance_{{ inputs.runner_name | slugify }}"
kvType: JSON
overwrite: true
value: |
{{
{
"runner_name": inputs.runner_name,
"runner_host": inputs.runner_host,
"github_organization": inputs.github_organization,
"github_status": outputs.poll_github_runners.vars.target_runner_status,
"runner_busy": outputs.poll_github_runners.vars.target_runner_busy,
"action_taken": (outputs.calculate_reclaimed_metrics.vars.action_taken ?? outputs.record_healthy_metrics.values.action_taken ?? 'UNKNOWN'),
"threshold_pct": inputs.disk_threshold_pct,
"pre_disk_used_pct": outputs.check_runner_disk_pre.vars.disk_used_pct,
"post_disk_used_pct": (outputs.check_runner_disk_post.vars.disk_used_pct ?? outputs.check_runner_disk_pre.vars.disk_used_pct),
"reclaimed_gb": (outputs.calculate_reclaimed_metrics.vars.reclaimed_gb ?? 0.0),
"executed_at": now()
} | toJson
}}
- id: notify_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send disk prune audit summary and reclaimed capacity metrics to Slack.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{{
{
"text": "🧹 *GitHub Actions Runner Disk Maintenance Report*\n" ~
"• *Runner:* `" ~ inputs.runner_name ~ "` (`" ~ inputs.runner_host ~ "`)\n" ~
"• *Fleet Status:* `" ~ outputs.poll_github_runners.vars.target_runner_status ~ "` (Org: `" ~ inputs.github_organization ~ "`)\n" ~
"• *Initial Disk Usage:* *" ~ outputs.check_runner_disk_pre.vars.disk_used_pct ~ "%* (Threshold: " ~ inputs.disk_threshold_pct ~ "%)\n" ~
"• *Action Taken:* `" ~ (outputs.calculate_reclaimed_metrics.vars.action_taken ?? outputs.record_healthy_metrics.values.action_taken ?? 'UNKNOWN') ~ "`\n" ~
"• *Reclaimed Storage:* *" ~ (outputs.calculate_reclaimed_metrics.vars.reclaimed_gb ?? 0.0) ~ " GB* (Final Usage: " ~ (outputs.check_runner_disk_post.vars.disk_used_pct ?? outputs.check_runner_disk_pre.vars.disk_used_pct) ~ "%)\n" ~
"• *Dry Run:* `" ~ inputs.dry_run ~ "`"
} | toJson
}}
errors:
- id: alert_maintenance_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send alert to Slack when the runner maintenance workflow encounters
an unhandled error.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{{
{
"text": "🚨 *GitHub Actions Runner Maintenance FAILED*\n" ~
"• *Flow:* `" ~ flow.id ~ "` (Execution: `" ~ execution.id ~ "`)\n" ~
"• *Runner:* `" ~ inputs.runner_name ~ "` (`" ~ inputs.runner_host ~ "`)\n" ~
"• *Alert:* An error occurred during runner API interrogation, SSH command execution, or disk cleanup. Check Kestra execution logs for details."
} | toJson
}}