Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b6e42b8
Improve integration test and add basic usage example
aru-amedeo Apr 27, 2026
5cdaa3f
Fix SC2129: group consecutive GITHUB_OUTPUT redirects
aru-amedeo Apr 27, 2026
5a1398f
fix: add --project-id to all acloud create/get commands
aru-amedeo Apr 28, 2026
dd5b382
debug: print boot-disk create response to diagnose jq parse error
aru-amedeo Apr 28, 2026
e62d8cb
fix: parse acloud text output with grep/awk instead of jq (--output j…
aru-amedeo Apr 28, 2026
7ea47aa
fix: improve cleanup trap - log output, delete server before disk, ad…
aru-amedeo Apr 28, 2026
f225777
fix: write outputs eagerly per resource so stop-runner cleans up on p…
aru-amedeo Apr 28, 2026
7697cbe
fix: retry acloud-cli download up to 3 times on transient failures
aru-amedeo Apr 28, 2026
f383f4f
fix: retry cloudserver create on 5xx with debug output on failure
aru-amedeo Apr 28, 2026
d31eee5
fix: show debug output on cloudserver create failure
aru-amedeo Apr 28, 2026
6568f84
fix: make delete idempotent and remove --zone from cloudserver create
aru-amedeo May 5, 2026
ea21a47
fix: revert --verbose to -d (correct acloud-cli debug flag)
aru-amedeo May 5, 2026
75fafdc
fix: restore --zone flag for cloudserver create (required by API)
aru-amedeo May 5, 2026
50cc26d
debug: print raw cloudserver get output on first poll to diagnose sta…
aru-amedeo May 5, 2026
7b962f8
fix: parse server ID from cloudserver create table output (NR==2 \ no…
aru-amedeo May 5, 2026
25cff46
fix: add runner name as a label so runs-on can route jobs to it
aru-amedeo May 5, 2026
7c156d8
cleanup: remove debug cat of server-status.txt
aru-amedeo May 5, 2026
d00e986
chore: ignore local test config file (may contain credentials)
aru-amedeo May 5, 2026
a3f9bd4
updated README to publish action-runner in Github Action Marketplace
aru-amedeo May 13, 2026
13c4db9
review action.yml before publish
aru-amedeo May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 83 additions & 8 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,31 @@ name: Integration Test

on:
workflow_dispatch:
inputs:
flavor:
description: 'Server flavor (e.g. CSO2A4, CSO4A8)'
required: false
default: CSO2A4
image:
description: 'Boot image (e.g. LU22-001, LU24-001)'
required: false
default: LU22-001

# Prevent overlapping integration runs — each run creates real cloud resources.
# Do not cancel in-progress runs; let them finish and clean up first.
concurrency:
group: integration-test
cancel-in-progress: false

permissions:
contents: read

jobs:
# ── Job 1: provision the ephemeral runner ───────────────────────────────────
start-runner:
name: Start ephemeral runner
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
label: ${{ steps.runner.outputs.label }}
server_id: ${{ steps.runner.outputs.server_id }}
Expand All @@ -30,27 +47,57 @@ jobs:
subnet_uri: ${{ secrets.ACLOUD_SUBNET_URI }}
security_group_uri: ${{ secrets.ACLOUD_SECURITY_GROUP_URI }}
keypair_uri: ${{ secrets.ACLOUD_KEYPAIR_URI }}
flavor: CSO2A4
image: LU22-001
name: test-${{ github.run_id }}-${{ github.run_attempt }}
flavor: ${{ inputs.flavor }}
image: ${{ inputs.image }}

# ── Job 2: the actual workload (runs on the ephemeral runner) ───────────────
test:
name: Run on ephemeral runner
needs: start-runner
runs-on: ${{ needs.start-runner.outputs.label }}
timeout-minutes: 10
steps:
- name: Verify runner environment
- name: System info
run: |
echo "=== OS ==="
uname -a
cat /etc/os-release
echo "=== User ==="
whoami
echo "=== Runner name: $RUNNER_NAME ==="
echo "=== Disk ==="
df -h /

- name: Verify required tools installed by cloud-init
run: |
echo "Running on: $(uname -a)"
echo "User: $(whoami)"
echo "Runner name: $RUNNER_NAME"
for cmd in curl git jq tar; do
if command -v "$cmd" >/dev/null 2>&1; then
echo "OK: $cmd found at $(command -v "$cmd")"
else
echo "FAIL: $cmd not found" && exit 1
fi
done

- name: Verify acloud-cli is not required on ephemeral runner
run: echo "Job completed successfully on Aruba Cloud runner."
- name: Verify internet connectivity
run: |
curl -fsSL --max-time 15 https://github.com > /dev/null
echo "OK: GitHub reachable"

- name: Verify runner is ephemeral
run: |
if [[ "$RUNNER_EPHEMERAL" != "1" ]]; then
echo "FAIL: runner is not ephemeral (RUNNER_EPHEMERAL='$RUNNER_EPHEMERAL')"
exit 1
fi
echo "OK: runner is ephemeral"

# ── Job 3: tear down the runner and cloud resources ─────────────────────────
stop-runner:
name: Stop ephemeral runner
needs: [start-runner, test]
runs-on: ubuntu-latest
timeout-minutes: 15
if: always()
steps:
- uses: actions/checkout@v4
Expand All @@ -65,3 +112,31 @@ jobs:
server_id: ${{ needs.start-runner.outputs.server_id }}
boot_disk_id: ${{ needs.start-runner.outputs.boot_disk_id }}
name: ${{ needs.start-runner.outputs.label }}

# Confirm the server is no longer reachable via the API.
# We treat "not-found" and "Deleting" as both acceptable final states.
- name: Verify server is deleted
if: needs.start-runner.outputs.server_id != ''
env:
ACLOUD_CLIENT_ID: ${{ secrets.ACLOUD_CLIENT_ID }}
ACLOUD_CLIENT_SECRET: ${{ secrets.ACLOUD_CLIENT_SECRET }}
ACLOUD_PROJECT_ID: ${{ needs.start-runner.outputs.project_id }}
SERVER_ID: ${{ needs.start-runner.outputs.server_id }}
run: |
acloud config set \
--client-id "$ACLOUD_CLIENT_ID" \
--client-secret "$ACLOUD_CLIENT_SECRET"
acloud context set default --project-id "$ACLOUD_PROJECT_ID"

STATUS=$(acloud compute cloudserver get "$SERVER_ID" \
--output json 2>/dev/null \
| jq -r '.status // empty' || echo "not-found")

echo "Post-delete server status: '${STATUS}'"

case "$STATUS" in
not-found|Deleting)
echo "OK: server is being removed." ;;
*)
echo "WARNING: unexpected server status '$STATUS' — verify manually." ;;
esac
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Example/reference projects - not tracked in this repo
others/

# Local test config (may contain credentials)
test
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# acloud-github-runner
# Self-Hosted GitHub Actions Runner on Aruba Cloud

On-demand self-hosted GitHub Actions runners on [Aruba Cloud](https://www.arubacloud.com).

<p align="center">
<img src="logo.png" alt="ArubaCloud Logo" width="200"/>
</p>

Each workflow gets a **fresh, ephemeral cloud server**. The server is created at the start of the job and deleted at the end — no idle costs, no shared state between runs.

---
Expand Down
143 changes: 101 additions & 42 deletions action.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,25 @@ function _cleanup_on_exit() {
local code=$?
[[ $code -eq 0 ]] && return

echo >&2 "--- Cleanup triggered (exit code: $code) ---"

if [[ -n "$_CREATED_SERVER_ID" ]]; then
echo >&2 "Create failed — deleting orphan server '$_CREATED_SERVER_ID'..."
acloud compute cloudserver delete "$_CREATED_SERVER_ID" \
--project-id "$MY_ACLOUD_PROJECT_ID" \
--yes 2>/dev/null \
&& echo >&2 "Server '$_CREATED_SERVER_ID' deleted." \
|| echo >&2 "Warning: could not auto-delete server '$_CREATED_SERVER_ID'. Remove it manually."
# Wait briefly for the server to release the boot disk before deleting it
sleep 10
fi

if [[ -n "$_CREATED_BOOT_DISK_ID" ]]; then
echo >&2 "Deleting orphan boot disk '$_CREATED_BOOT_DISK_ID'..."
acloud storage blockstorage delete "$_CREATED_BOOT_DISK_ID" \
--project-id "$MY_ACLOUD_PROJECT_ID" \
--yes 2>/dev/null \
&& echo >&2 "Boot disk '$_CREATED_BOOT_DISK_ID' deleted." \
|| echo >&2 "Warning: could not auto-delete boot disk '$_CREATED_BOOT_DISK_ID'. Remove it manually."
fi
}
Expand Down Expand Up @@ -124,22 +130,26 @@ acloud context set default --project-id "$MY_ACLOUD_PROJECT_ID"
# ─── DELETE ───────────────────────────────────────────────────────────────────

if [[ "$MY_MODE" == "delete" ]]; then
[[ -n "$MY_SERVER_ID" ]] || exit_with_failure "server_id is required for delete mode."

echo "Deleting Aruba Cloud server '$MY_SERVER_ID' (project: $MY_ACLOUD_PROJECT_ID)..."
acloud compute cloudserver delete "$MY_SERVER_ID" \
--project-id "$MY_ACLOUD_PROJECT_ID" \
--yes \
|| exit_with_failure "Failed to delete server '$MY_SERVER_ID'."
echo "Server deleted."
if [[ -n "$MY_SERVER_ID" ]]; then
echo "Deleting Aruba Cloud server '$MY_SERVER_ID' (project: $MY_ACLOUD_PROJECT_ID)..."
acloud compute cloudserver delete "$MY_SERVER_ID" \
--project-id "$MY_ACLOUD_PROJECT_ID" \
--yes \
&& echo "Server deleted." \
|| echo "Warning: could not delete server '$MY_SERVER_ID' (may already be gone)."
# Wait for the server to release the boot disk before deleting it
sleep 10
else
echo "No server_id provided — skipping server deletion."
fi

if [[ -n "$MY_BOOT_DISK_ID" ]]; then
echo "Deleting boot disk '$MY_BOOT_DISK_ID'..."
acloud storage blockstorage delete "$MY_BOOT_DISK_ID" \
--project-id "$MY_ACLOUD_PROJECT_ID" \
--yes \
|| exit_with_failure "Failed to delete boot disk '$MY_BOOT_DISK_ID'."
echo "Boot disk deleted."
&& echo "Boot disk deleted." \
|| echo "Warning: could not delete boot disk '$MY_BOOT_DISK_ID' (may already be gone)."
fi

# Best-effort cleanup of the GitHub runner entry. Ephemeral runners
Expand Down Expand Up @@ -171,7 +181,11 @@ if [[ "$MY_MODE" == "delete" ]]; then
fi

echo "Cleanup complete."
echo "Aruba Cloud server and boot disk deleted successfully. 🗑️" >> "$GITHUB_STEP_SUMMARY"
if [[ -n "$MY_SERVER_ID" || -n "$MY_BOOT_DISK_ID" ]]; then
echo "Aruba Cloud resources deleted successfully. 🗑️" >> "$GITHUB_STEP_SUMMARY"
else
echo "No Aruba Cloud resources to delete." >> "$GITHUB_STEP_SUMMARY"
fi
exit 0
fi

Expand Down Expand Up @@ -230,12 +244,22 @@ acloud storage blockstorage create \
--size "$MY_BOOT_DISK_SIZE" \
--type "$MY_BOOT_DISK_TYPE" \
--image "$MY_IMAGE" \
--output json > boot-disk-create.json \
--project-id "$MY_ACLOUD_PROJECT_ID" \
> boot-disk-create.txt \
|| exit_with_failure "Failed to create boot disk."

MY_BOOT_DISK_ID=$(jq -er '.id' < boot-disk-create.json)
cat boot-disk-create.txt

MY_BOOT_DISK_ID=$(grep -E '^ID:' boot-disk-create.txt | awk '{print $NF}')
[[ -n "$MY_BOOT_DISK_ID" ]] || exit_with_failure "Could not parse boot disk ID from create response."
_CREATED_BOOT_DISK_ID="$MY_BOOT_DISK_ID" # arm cleanup trap
_CREATED_BOOT_DISK_ID="$MY_BOOT_DISK_ID" # arm cleanup trap — must be set before any subsequent exit

# Write boot_disk_id and project_id to GITHUB_OUTPUT immediately so stop-runner
# can delete the disk even if subsequent steps fail.
{
echo "boot_disk_id=$MY_BOOT_DISK_ID"
echo "project_id=$MY_ACLOUD_PROJECT_ID"
} >> "$GITHUB_OUTPUT"

echo "Boot disk created (ID: $MY_BOOT_DISK_ID). Waiting for 'NotUsed' status..."

Expand All @@ -244,10 +268,10 @@ echo "Boot disk created (ID: $MY_BOOT_DISK_ID). Waiting for 'NotUsed' status..."
MY_BOOT_DISK_STATUS=""
RETRY_COUNT=0
while [[ $RETRY_COUNT -lt $MY_BOOT_DISK_WAIT ]]; do
acloud storage blockstorage get "$MY_BOOT_DISK_ID" --output json \
> boot-disk-status.json 2>/dev/null || true
acloud storage blockstorage get "$MY_BOOT_DISK_ID" --project-id "$MY_ACLOUD_PROJECT_ID" \
> boot-disk-status.txt 2>/dev/null || true

MY_BOOT_DISK_STATUS=$(jq -er '.status // empty' < boot-disk-status.json 2>/dev/null || true)
MY_BOOT_DISK_STATUS=$(grep -E '^Status:' boot-disk-status.txt | awk '{print $NF}' || true)

if [[ "$MY_BOOT_DISK_STATUS" == "NotUsed" ]]; then
echo "Boot disk is ready (NotUsed)."
Expand All @@ -263,39 +287,74 @@ done
exit_with_failure "Boot disk did not reach 'NotUsed' in time. Check the Aruba Cloud console."

# Get the boot disk URI (required to attach it to the cloudserver)
MY_BOOT_DISK_URI=$(jq -er '.uri // .URI // empty' < boot-disk-status.json)
MY_BOOT_DISK_URI=$(grep -E '^URI:' boot-disk-status.txt | awk '{print $NF}')
[[ -n "$MY_BOOT_DISK_URI" ]] || exit_with_failure "Could not parse boot disk URI from status response."
echo "Boot disk URI: $MY_BOOT_DISK_URI"

# ── Step 3: Create the cloudserver ───────────────────────────────────────────

SERVER_CREATE_MAX_ATTEMPTS=3
SERVER_CREATE_RETRY_WAIT=15

_run_cloudserver_create() {
local extra_flags=("$@")
acloud compute cloudserver create \
"${extra_flags[@]}" \
--name "$MY_NAME" \
--region "$MY_REGION" \
--zone "$MY_ZONE" \
--flavor "$MY_FLAVOR" \
--boot-disk-uri "$MY_BOOT_DISK_URI" \
--vpc-uri "$MY_VPC_URI" \
--subnet-uri "$MY_SUBNET_URI" \
--security-group-uri "$MY_SECURITY_GROUP_URI" \
--keypair-uri "$MY_KEYPAIR_URI" \
--user-data-file cloud-init.yml \
--project-id "$MY_ACLOUD_PROJECT_ID"
}

echo "Creating Aruba Cloud server '$MY_NAME'..."
acloud compute cloudserver create \
--name "$MY_NAME" \
--region "$MY_REGION" \
--zone "$MY_ZONE" \
--flavor "$MY_FLAVOR" \
--boot-disk-uri "$MY_BOOT_DISK_URI" \
--vpc-uri "$MY_VPC_URI" \
--subnet-uri "$MY_SUBNET_URI" \
--security-group-uri "$MY_SECURITY_GROUP_URI" \
--keypair-uri "$MY_KEYPAIR_URI" \
--user-data-file cloud-init.yml \
--output json > server-create.json \
|| exit_with_failure "Failed to create Aruba Cloud server."

MY_ACLOUD_SERVER_ID=$(jq -er '.id' < server-create.json)
_server_create_attempt=0
while true; do
_server_create_attempt=$(( _server_create_attempt + 1 ))
echo "Server create attempt ${_server_create_attempt}/${SERVER_CREATE_MAX_ATTEMPTS}..."
if _run_cloudserver_create > server-create.txt 2>&1; then
break
fi

_server_create_err=$(cat server-create.txt)
echo >&2 "Attempt ${_server_create_attempt} failed: ${_server_create_err}"

# On any failure, print debug output to aid diagnosis
echo >&2 "--- Debug output for failed attempt ${_server_create_attempt} ---"
_run_cloudserver_create -d || true
if echo "$_server_create_err" | grep -qE 'status 5[0-9]{2}'; then
if [[ $_server_create_attempt -lt $SERVER_CREATE_MAX_ATTEMPTS ]]; then
echo "Transient server error — retrying in ${SERVER_CREATE_RETRY_WAIT}s..."
sleep "$SERVER_CREATE_RETRY_WAIT"
continue
fi
fi

exit_with_failure "Failed to create Aruba Cloud server."
done

cat server-create.txt

MY_ACLOUD_SERVER_ID=$(awk 'NR==2 {print $1}' server-create.txt)
[[ -n "$MY_ACLOUD_SERVER_ID" ]] || exit_with_failure "Could not parse server ID from create response."
_CREATED_SERVER_ID="$MY_ACLOUD_SERVER_ID" # arm cleanup trap

# Write server_id to GITHUB_OUTPUT immediately so stop-runner can delete it
# even if the runner polling timeout or any subsequent step fails.
echo "server_id=$MY_ACLOUD_SERVER_ID" >> "$GITHUB_OUTPUT"

echo "Server created (ID: $MY_ACLOUD_SERVER_ID)."

# Publish outputs immediately so the delete step has all IDs even if
# later polling fails. Server + project + boot disk are all needed for cleanup.
echo "label=$MY_NAME" >> "$GITHUB_OUTPUT"
echo "server_id=$MY_ACLOUD_SERVER_ID" >> "$GITHUB_OUTPUT"
echo "project_id=$MY_ACLOUD_PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "boot_disk_id=$MY_BOOT_DISK_ID" >> "$GITHUB_OUTPUT"
# Write remaining outputs (label already known from the start).
{
echo "label=$MY_NAME"
} >> "$GITHUB_OUTPUT"

# ── Step 4: Poll server until Active ─────────────────────────────────────────
# Aruba Cloud resources must be "Active" before they can be used.
Expand All @@ -304,10 +363,10 @@ echo "Waiting for server to become Active..."
MY_SERVER_STATUS=""
RETRY_COUNT=0
while [[ $RETRY_COUNT -lt $MY_SERVER_WAIT ]]; do
acloud compute cloudserver get "$MY_ACLOUD_SERVER_ID" --output json \
> server-status.json 2>/dev/null || true
acloud compute cloudserver get "$MY_ACLOUD_SERVER_ID" --project-id "$MY_ACLOUD_PROJECT_ID" \
> server-status.txt 2>/dev/null || true

MY_SERVER_STATUS=$(jq -er '.status // empty' < server-status.json 2>/dev/null || true)
MY_SERVER_STATUS=$(grep -E '^Status:' server-status.txt | awk '{print $NF}' || true)

if [[ "$MY_SERVER_STATUS" == "Active" ]]; then
echo "Server is Active."
Expand Down
Loading
Loading