Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @rhuanbarreto
9 changes: 9 additions & 0 deletions .github/ISSUE_TEMPLATE/bug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: Bug report
about: Report a bug or suggest an improvement
title: ""
labels: ""
assignees: ""
---

Describe the expected behavior and relevant context. Remove secrets from all logs.
9 changes: 9 additions & 0 deletions .github/ISSUE_TEMPLATE/feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: Feature report
about: Report a feature or suggest an improvement
title: ""
labels: ""
assignees: ""
---

Describe the expected behavior and relevant context. Remove secrets from all logs.
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
7 changes: 7 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Changes

Describe the behavior and motivation.

## Validation

List tests and lint results.
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- name: Install lint tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
curl -fsSL https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz -o /tmp/actionlint_1.7.7_linux_amd64.tar.gz
curl -fsSL https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_checksums.txt -o /tmp/checksums.txt
(cd /tmp && sha256sum --check --ignore-missing checksums.txt)
tar -xzf /tmp/actionlint_1.7.7_linux_amd64.tar.gz -C /tmp actionlint
python -m pip install zizmor==1.31.1
- name: Tests and linters
run: |
bash tests/test.sh
python3 tests/service_test.py
python3 tests/request_test.py
python3 tests/release_test.py
shellcheck src/*.sh scripts/*.sh tests/*.sh
/tmp/actionlint
zizmor --offline .
python3 scripts/check-public.py
fallback:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: ./ # zizmor: ignore[self-repository] -- Exercise the checked-out PR action, including unmerged changes.
id: pick
with:
self-hosted: '["self-hosted", "linux"]'
- name: Check no-token fallback
env:
RESULT: ${{ steps.pick.outputs.runs-on }}
CHOICE: ${{ steps.pick.outputs.choice }}
REASON: ${{ steps.pick.outputs.reason }}
run: |
test "$RESULT" = '["self-hosted","linux"]'
test "$CHOICE" = self-hosted
test "$REASON" = 'fallback: decision unavailable'
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Release
on:
push:
tags: ['v*.*.*']
permissions:
contents: read
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
fetch-depth: 0
- name: Validate release and publish
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPOSITORY: ${{ github.repository }}
run: |
[[ "$TAG" =~ ^v([0-9]+)\.[0-9]+\.[0-9]+$ ]] || exit 1
major="v${BASH_REMATCH[1]}"
sha=$(git rev-parse HEAD)
bash tests/test.sh
python3 tests/service_test.py
python3 tests/request_test.py
python3 tests/release_test.py
python3 scripts/check-public.py
gh release create "$TAG" --repo "$REPOSITORY" --verify-tag --generate-notes
if ! bash scripts/is-latest-release.sh "$TAG"; then
echo '::notice::Published release without moving the major tag: a newer version exists.'
exit 0
fi
if gh api "repos/$REPOSITORY/git/ref/tags/$major" >/dev/null 2>&1; then
gh api --method PATCH "repos/$REPOSITORY/git/refs/tags/$major" -f sha="$sha" -F force=true
else
gh api --method POST "repos/$REPOSITORY/git/refs" -f ref="refs/tags/$major" -f sha="$sha"
fi
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and Semantic Versioning.

## [Unreleased]

### Added
- Composite runner selection with GitHub and capacity service sources.
- Optional federated private networking, reference service, tests and maintenance workflows.

### Fixed
- Supply the federated identity audience for optional private networking.
- Preserve the service provider wire protocol while exposing generic overflow outputs.
- Fall back on all HTTP statuses other than 200, including redirects.
- Keep major release tags on the highest stable version in their major series.
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Contributing

Open an issue for substantial changes. Submit focused pull requests with a description and validation results. Use Bash and jq for the action; keep the service example dependency-free.

Run `bash tests/test.sh`, `python3 tests/service_test.py`, `python3 tests/request_test.py`, `python3 tests/release_test.py`, `shellcheck src/*.sh scripts/*.sh tests/*.sh`, `actionlint`, `zizmor --offline .`, and `python3 scripts/check-public.py` before submitting. Test tools are pinned in `.github/workflows/ci.yml`. Bash, jq, curl and Python 3 are prerequisites. Never include credentials, private infrastructure details, or raw API responses in logs or fixtures.

Releases are maintainer-driven: update the changelog, merge reviewed changes, then push a semantic version tag such as `v1.0.0`. The release workflow publishes release notes and advances the major tag only when the new version is the highest stable tag in that major series. Review major-tag changes before use; consumers should pin a full commit SHA.
124 changes: 124 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,125 @@
# Runner Picker

Choose a runner pool before a downstream job is queued. Runner Picker uses a small preparation job to select self-hosted labels when matching runners appear idle, or an overflow target when the pool is busy. The overflow can be a GitHub-hosted runner or another provider, such as Blacksmith.

This is a composite action using Bash, jq and curl, available on GitHub-hosted Linux runners. There is no JavaScript build step for the picker. Optional Tailscale networking uses its upstream action and runtime.

## Quick start: GitHub runner API

```yaml
name: Build
on: [push]
permissions:
contents: read
jobs:
pick:
runs-on: ubuntu-latest
outputs:
runs-on: ${{ steps.runner.outputs.runs-on }}
steps:
- uses: BarretoTech/runner-picker@v1
id: runner
with:
self-hosted: '["self-hosted", "linux", "build"]'
token: ${{ secrets.RUNNER_READ_TOKEN }}
# Optional provider label, tried before hosted:
# overflow: '"your-provider-label"'
hosted: '"ubuntu-latest"'
build:
needs: pick
runs-on: ${{ fromJSON(needs.pick.outputs.runs-on) }}
steps:
- run: echo "Build on the selected pool"
```

`v1` is a moving major tag created with the first release. Before release, use a reviewed commit SHA. For production, replace `@v1` with the full SHA of the release you reviewed.

Provide a GitHub App installation token or fine-grained PAT with organization **Self-hosted runners: read** permission. For `runner-scope: repo`, use repository **Administration: read** (repository administrator access). Install/authorize the token for the selected organization or repository. `GITHUB_TOKEN` cannot list self-hosted runners; increasing workflow `permissions` does not grant it this access. The owner and repository come from `github.repository`, and the API base comes from `github.api_url`.

## Quick start: capacity service

Use the same two-job structure, replacing the picker step with:

```yaml
- uses: BarretoTech/runner-picker@v1
id: runner
with:
source: service
self-hosted: '["self-hosted", "linux"]'
capacity-url: https://capacity.example/capacity
overflow: '"your-provider-label"'
weight: '1'
```

Run your own service implementing the contract below. A [dependency-free Python example](examples/capacity-service/) queries GitHub on the server and keeps the API token there.

For an optional private service over Tailscale, add `tailnet-client-id: ${{ vars.CAPACITY_NETWORK_CLIENT }}` and `tailnet-tags: tag:ci`. The action supplies audience `api.tailscale.com/<client-id>`; configure that exact audience in the identity. Configure a Tailscale federated identity trusting the caller repository/workflow and its tags, then grant that identity access only to the service. Give the picker job `permissions: {contents: read, id-token: write}`. No Tailscale client secret is required. The SHA-pinned upstream action runs in userspace mode; curl uses its HTTP proxy at `localhost:1055` only for the capacity request. Connection failure falls back to self-hosted. Do not run multiple network setup actions in the same job. Plain HTTP is supported for private networks; use HTTPS for public services.

### Service contract

The action performs a GET on `capacity-url` with URL-encoded parameters:

| Parameter | Meaning |
| --- | --- |
| `labels` | Required self-hosted labels, comma-separated |
| `weight` | Positive integer request weight |
| `org` | Caller repository owner; sent only with an overflow offer |
| `blacksmith` | Optional overflow offer; a string target, or the first label of an array target |

Labels used with a service must not contain commas. The endpoint returns HTTP 200 and JSON `{"choice":"self-hosted","reason":"idle runners available"}`. Wire choices are `self-hosted`, `blacksmith`, and `hosted`. The `blacksmith` wire value selects the configured `overflow` input and is exposed as public output `choice: overflow`; selecting it without an input is an error. The provider-specific wire name is retained for service compatibility, even when the configured target uses another provider. `hosted` selects the hosted input. The reason must be a nonempty, single-line string of at most 500 characters, without percent or control characters. Malformed responses, any HTTP status other than 200 and timeouts return the self-hosted fallback. The action never sends its `token` input to the service. Protect the service through network access controls or your authenticated gateway.

## Inputs

All runs-on inputs are JSON, including strings: use `'"ubuntu-latest"'`, not `'ubuntu-latest'`.

| Input | Default | Description |
| --- | --- | --- |
| `self-hosted` | `["self-hosted"]` | Nonempty JSON array of required labels |
| `hosted` | `"ubuntu-latest"` | Final alternate runs-on JSON string or array |
| `overflow` | empty | Optional alternate JSON string or array preferred over hosted |
| `source` | `github` | `github` or `service` |
| `token` | empty | Runner read token; only used by GitHub source |
| `runner-scope` | `org` | `org` or `repo`; GitHub source |
| `min-idle` | `1` | Matching idle runners needed; GitHub source |
| `capacity-url` | empty | Required HTTP(S) endpoint for service source |
| `tailnet-client-id` | empty | Optional Tailscale federated identity client ID |
| `tailnet-tags` | `tag:ci` | Comma-separated identity tags |
| `weight` | `1` | Service request weight, interpreted by the service |

Integers must be between 1 and 999999. Invalid inputs produce a clear error annotation and successful action completion with self-hosted fallback. If `self-hosted` itself is invalid, the only usable fallback is `["self-hosted"]`.

## Outputs

| Output | Description |
| --- | --- |
| `runs-on` | Compact JSON for `fromJSON(...)` |
| `choice` | `self-hosted`, `overflow`, or `hosted` |
| `reason` | Decision explanation, or `fallback: decision unavailable` |

## How it decides

The GitHub source lists `/orgs/{org}/actions/runners` or `/repos/{owner}/{repo}/actions/runners` with `per_page=100`, pagination and `X-GitHub-Api-Version: 2022-11-28`. A runner counts only when online, not busy, and carrying every requested label, compared case-insensitively. At least `min-idle` matches selects self-hosted; otherwise a configured overflow is selected, then hosted. This does not probe overflow availability. The service source delegates that decision, passing `weight` instead of `min-idle`.

Any API, parsing, validation or network error keeps self-hosted labels so capacity detection does not fail the job. Requests have connection and total timeouts. Runner listings stop at 1000 pages; incomplete listings fail safely.

## Security

- Pin this action and other workflow actions to full commit SHAs. Review updates before adopting them.
- Use a least-privilege, short-lived GitHub App token where possible. This action never prints the token or API response bodies. Do not enable shell tracing around secrets.
- Fork pull requests receive no secrets by default and therefore fall back to self-hosted. **Only use that fallback for code you trust to execute on your self-hosted fleet.** Gate untrusted contributions with workflow policy; never expose privileged runners or secrets to untrusted code via `pull_request_target`.
- Limit federated identity trust and private network policy to the intended workflow and service. Grant `id-token: write` only when networking needs it.
- Consult [SECURITY.md](SECURITY.md) for private reporting.

## Limits and FAQ

**Is this a reservation?** No. An idle runner can be claimed immediately after the API snapshot. Queued jobs may already be waiting for it. Concurrent pickers can choose the same idle capacity. For stronger guarantees, implement reservation logic in your own service.

**Why does it still queue?** Runner groups, repository visibility, workflow restrictions and other scheduler policies are not evaluated. The API may list a runner that this job cannot use. Configure labels and access carefully.

**Can the action move its own job?** No. Run it in a separate preparation job and use the output on a dependent job, as shown above. The preparation job has its own startup time and cost.

**Does overflow guarantee success?** No. Provider labels, entitlement and capacity must be configured by the caller. The action only selects runs-on; downstream jobs can still fail or wait.

**Are runner groups supported?** Runs-on objects are currently unsupported. Use string/array targets and labels unique to the desired pool. The Bash picker is intended for Linux preparation jobs with Bash, jq and curl installed.

See [CONTRIBUTING.md](CONTRIBUTING.md), [CHANGELOG.md](CHANGELOG.md) and the [MIT license](LICENSE).
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Security policy

Report vulnerabilities privately using [GitHub private vulnerability reporting](https://github.com/BarretoTech/runner-picker/security/advisories/new). If that form is unavailable, email rhuan@barreto.work. Do not post tokens or sensitive runner information in public issues.

The latest release in major version 1 receives security fixes. Until the first release, use a reviewed commit SHA. Reports will receive an initial response within seven days where possible.
91 changes: 91 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Runner Picker
description: Choose a runner pool before a downstream job is queued
branding:
icon: shuffle
color: blue
inputs:
self-hosted:
description: "JSON array of required self-hosted labels"
required: false
default: "[\"self-hosted\"]"
hosted:
description: "Final overflow runs-on as a JSON string or array"
required: false
default: "\"ubuntu-latest\""
overflow:
description: "Optional runs-on JSON preferred over hosted"
required: false
default: ""
source:
description: "Decision source: github or service"
required: false
default: "github"
token:
description: "GitHub App installation token or fine-grained PAT that can read runners"
required: false
default: ""
runner-scope:
description: "Runner API scope: org or repo"
required: false
default: "org"
min-idle:
description: "Minimum matching idle runners for the GitHub source"
required: false
default: "1"
capacity-url:
description: "Capacity service GET endpoint"
required: false
default: ""
tailnet-client-id:
description: "Optional Tailscale federated identity client ID"
required: false
default: ""
tailnet-tags:
description: "Comma-separated Tailscale tags"
required: false
default: "tag:ci"
weight:
description: "Positive integer capacity request weight"
required: false
default: "1"
outputs:
runs-on:
description: JSON runs-on value for fromJSON
value: ${{ steps.pick.outputs.runs-on }}
choice:
description: self-hosted, overflow, or hosted
value: ${{ steps.pick.outputs.choice }}
reason:
description: Human-readable decision or fallback reason
value: ${{ steps.pick.outputs.reason }}
runs:
using: composite
steps:
- name: Connect optional private network
id: tailnet
if: inputs.source == 'service' && inputs.tailnet-client-id != ''
continue-on-error: true
uses: tailscale/github-action@064b10558093f38325e838ddea35486bdc6b1d5c # v4
with:
oauth-client-id: ${{ inputs.tailnet-client-id }}
audience: api.tailscale.com/${{ inputs.tailnet-client-id }}
tags: ${{ inputs.tailnet-tags }}
tailscaled-args: --tun=userspace-networking --outbound-http-proxy-listen=localhost:1055
use-cache: 'false'
- name: Pick runner
id: pick
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
SELF_HOSTED: ${{ inputs.self-hosted }}
HOSTED: ${{ inputs.hosted }}
OVERFLOW: ${{ inputs.overflow }}
SOURCE: ${{ inputs.source }}
TOKEN: ${{ inputs.token }}
RUNNER_SCOPE: ${{ inputs.runner-scope }}
MIN_IDLE: ${{ inputs.min-idle }}
CAPACITY_URL: ${{ inputs.capacity-url }}
TAILNET_CLIENT_ID: ${{ inputs.tailnet-client-id }}
TAILNET_OUTCOME: ${{ steps.tailnet.outcome }}
WEIGHT: ${{ inputs.weight }}
run: bash "$ACTION_PATH/src/pick.sh"
Loading
Loading