From cc67228018e975868f3e837b09f9cf2aadf7609d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 7 Nov 2026 20:19:39 +0100 Subject: [PATCH 1/2] feat: add reusable runner selection action and maintenance tooling --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug.md | 9 ++ .github/ISSUE_TEMPLATE/feature.md | 9 ++ .github/dependabot.yml | 8 ++ .github/pull_request_template.md | 7 ++ .github/workflows/ci.yml | 54 ++++++++++++ .github/workflows/release.yml | 38 +++++++++ .gitignore | 2 + CHANGELOG.md | 9 ++ CONTRIBUTING.md | 7 ++ README.md | 124 ++++++++++++++++++++++++++++ SECURITY.md | 5 ++ action.yml | 90 ++++++++++++++++++++ examples/capacity-service/README.md | 14 ++++ examples/capacity-service/server.py | 71 ++++++++++++++++ scripts/check-public.py | 39 +++++++++ src/pick.sh | 103 +++++++++++++++++++++++ tests/request_test.py | 67 +++++++++++++++ tests/service_test.py | 40 +++++++++ tests/test.sh | 51 ++++++++++++ 20 files changed, 748 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 action.yml create mode 100644 examples/capacity-service/README.md create mode 100644 examples/capacity-service/server.py create mode 100644 scripts/check-public.py create mode 100644 src/pick.sh create mode 100644 tests/request_test.py create mode 100644 tests/service_test.py create mode 100644 tests/test.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..35e3396 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @rhuanbarreto diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..e21a74f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..fbb7c04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9cbb9b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1ea7ab9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Changes + +Describe the behavior and motivation. + +## Validation + +List tests and lint results. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b73329e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +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 + shellcheck src/*.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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1451c50 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +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 scripts/check-public.py + gh release create "$TAG" --repo "$REPOSITORY" --verify-tag --generate-notes + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f81c0ed --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..446936c --- /dev/null +++ b/CONTRIBUTING.md @@ -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`, `shellcheck src/*.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 to that exact commit. Review major-tag changes before use; consumers should pin a full commit SHA. diff --git a/README.md b/README.md index 8a0d989..14af7a1 100644 --- a/README.md +++ b/README.md @@ -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`. 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` | Optional caller repository owner | +| `overflow` | Optional alternate label, or comma-separated labels for an array | + +Labels used with a service must not contain commas. The endpoint returns HTTP 200 and JSON `{"choice":"self-hosted","reason":"idle runners available"}`. Valid choices are `self-hosted`, `overflow`, and `hosted`. `overflow` selects the configured overflow input; selecting it without an input is an error. `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, non-success HTTP status 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 | + +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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..42410a7 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..81c05ee --- /dev/null +++ b/action.yml @@ -0,0 +1,90 @@ +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 }} + 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" diff --git a/examples/capacity-service/README.md b/examples/capacity-service/README.md new file mode 100644 index 0000000..2fb34ac --- /dev/null +++ b/examples/capacity-service/README.md @@ -0,0 +1,14 @@ +# Example capacity service + +This dependency-free Python 3 script demonstrates the contract. It is an example, not a production scheduler: it does not reserve capacity, authenticate callers, rate-limit requests or coordinate concurrent decisions. Put it behind authenticated HTTPS or a private network with restrictive access controls before exposing it. Keep the upstream API token on the service, not in query parameters. + +```bash +export RUNNER_OWNER=example-org +export RUNNER_SCOPE=org +# Supply RUNNER_TOKEN through your secret manager or environment. +python3 examples/capacity-service/server.py +``` + +`RUNNER_OWNER` is an organization for `org`, or `owner/repository` for `repo`. `RUNNER_TOKEN` needs runner read access. `BIND` defaults to `localhost`; `PORT` defaults to `8080`. + +GET `/capacity?labels=self-hosted,linux&weight=1&org=example-org&overflow=example-overflow` returns `{"choice":"self-hosted","reason":"idle runners available"}` or an overflow decision. Labels are comma-separated and matched without case sensitivity. Weight is the required number of idle runners. Optional `org` is checked against the configured owner; it cannot redirect credential use. Optional `overflow` indicates that an alternate target exists. Without it, insufficient capacity returns `hosted`. Errors return HTTP 503 so the action falls back to self-hosted. API calls paginate with 100 runners per page and use API version 2022-11-28. diff --git a/examples/capacity-service/server.py b/examples/capacity-service/server.py new file mode 100644 index 0000000..9e9c08c --- /dev/null +++ b/examples/capacity-service/server.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Example only: a snapshot-based capacity endpoint, not a reservation system.""" +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit, quote +from urllib.request import Request, urlopen + + +def decide(labels, weight, overflow, org): + scope = os.environ.get("RUNNER_SCOPE", "org") + configured = os.environ["RUNNER_OWNER"] + if org and org != configured.split("/")[0]: + raise ValueError("organization is not allowed") + if scope not in ("org", "repo"): + raise ValueError("invalid scope") + endpoint = "orgs" if scope == "org" else "repos" + owner = quote(configured, safe="/" if scope == "repo" else "") + idle = 0 + page = 1 + while True: + req = Request( + f"https://api.github.com/{endpoint}/{owner}/actions/runners?per_page=100&page={page}", + headers={"Authorization": "Bearer " + os.environ["RUNNER_TOKEN"], + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"}, + ) + with urlopen(req, timeout=15) as response: + data = json.load(response) + runners = data["runners"] + for runner in runners: + available = {label["name"].lower() for label in runner["labels"]} + if runner["status"] == "online" and runner["busy"] is False and set(labels) <= available: + idle += 1 + if page * 100 >= data["total_count"]: + break + if not runners or page >= 1000: + raise ValueError("incomplete runner listing") + page += 1 + return {"choice": "self-hosted" if idle >= weight else "overflow" if overflow else "hosted", + "reason": "idle runners available" if idle >= weight else "insufficient idle runners"} + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass # Do not log query strings or upstream errors. + + def do_GET(self): + try: + url = urlsplit(self.path) + if url.path != "/capacity": + self.send_error(404) + return + query = parse_qs(url.query, strict_parsing=True) + labels = query["labels"][0].lower().split(",") + weight = int(query.get("weight", ["1"])[0]) + if not all(labels) or not 1 <= weight <= 999999: + raise ValueError("invalid request") + result = decide(labels, weight, query.get("overflow", [""])[0], query.get("org", [""])[0]) + body = json.dumps(result).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except Exception: + self.send_error(503, "Capacity unavailable") + + +if __name__ == "__main__": + ThreadingHTTPServer((os.environ.get("BIND", "localhost"), int(os.environ.get("PORT", "8080"))), Handler).serve_forever() diff --git a/scripts/check-public.py b/scripts/check-public.py new file mode 100644 index 0000000..6c42498 --- /dev/null +++ b/scripts/check-public.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Reject private vocabulary and address literals in tracked content and history.""" +import hashlib +import pathlib +import re +import subprocess +import sys + +# One-way fingerprints avoid publishing the private vocabulary itself. +fingerprints = {'7b6cab976ab6fab4a7a9206824a8e17a0f50b4d67800d79bc5f7d426a2f89a3e', '670f3c7ad403e4459b0b475ced6d4d797677f8035ae204ffbe12de77c4b69e8d', '50af53d015f1cf829b7ab86bb9126afb3e6949b108ecb9728666dae24fe5e9f9', '5dd0d7c07e9221f516510958f149e7c2e6bc4b175af34e90e68349210fbb9047', '31611159e7e6ff7843ea4627745e89225fc866621cfcfdbd40871af4413747cc', '9b97ac5e767e5393cd0eed09a8e51b6f3775eba38f792593ea016e8ba9e996b0', '9e78b43ea00edcac8299e0cc8df7f6f913078171335f733a21d5d911b6999132', '9e9386c5119e89378942dc383794621bbf18bac3f0491be4ef48c8540cab7d46', 'd303b19ffeb0e7928510eb9d3a1693c9c8fbc5d17f89510c939f46649a109d64'} +sizes = [4, 8, 10, 11, 13, 24] +addresses = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +def restricted(text): + if addresses.search(text): + return True + for token in re.findall(r"[a-z0-9_-]+", text.lower()): + for size in sizes: + for offset in range(len(token) - size + 1): + digest = hashlib.sha256(token[offset:offset + size].encode()).hexdigest() + if digest in fingerprints: + return True + return False + + +failures = [] +for path in pathlib.Path(".").rglob("*"): + if path.is_file() and ".git" not in path.parts: + for number, line in enumerate(path.read_text(errors="replace").splitlines(), 1): + if restricted(line): + failures.append(f"{path}:{number}") +log = subprocess.check_output(["git", "log", "-p", "--all"], text=True) +if restricted(log): + failures.append("commit history") +if failures: + print("FAIL: restricted content at " + ", ".join(failures)) + sys.exit(1) +print("PASS: whole tree and git log -p --all: no prohibited strings or IPv4 addresses") diff --git a/src/pick.sh b/src/pick.sh new file mode 100644 index 0000000..5593cf9 --- /dev/null +++ b/src/pick.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Inputs are environment variables; fixture files bypass network access in tests. +set -Eeuo pipefail +SELF_HOSTED=${SELF_HOSTED:-'["self-hosted"]'} +HOSTED=${HOSTED:-'"ubuntu-latest"'} +OVERFLOW=${OVERFLOW:-} +SOURCE=${SOURCE:-github} +MIN_IDLE=${MIN_IDLE:-1} +WEIGHT=${WEIGHT:-1} +TOKEN=${TOKEN:-} +RUNNER_SCOPE=${RUNNER_SCOPE:-org} +CAPACITY_URL=${CAPACITY_URL:-} +TAILNET_CLIENT_ID=${TAILNET_CLIENT_ID:-} +result=$SELF_HOSTED +choice=self-hosted +reason='fallback: decision unavailable' +work=$(mktemp -d) +cleanup() { rm -rf "$work"; } +trap cleanup EXIT +emit() { + local target=${GITHUB_OUTPUT:-/dev/stdout} + printf 'runs-on=%s\nchoice=%s\nreason=%s\n' "$result" "$choice" "$reason" >> "$target" + printf '::notice::Runner picker: %s (%s)\n' "$choice" "$reason" +} +fail() { + printf '::notice::Runner picker: decision failed; using self-hosted labels\n' + result=$SELF_HOSTED; choice=self-hosted; reason='fallback: decision unavailable' + emit + exit 0 +} +invalid() { printf '::error::Runner picker: invalid %s\n' "$1"; fail; } +trap fail ERR +labels='type == "array" and length > 0 and all(.[]; type == "string" and length > 0 and (test("[\\x00-\\x1f\\x7f]") | not))' +target='(type == "string" and length > 0) or (type == "array" and length > 0 and all(.[]; type == "string" and length > 0))' +if ! SELF_HOSTED=$(jq -sce "select(length == 1) | .[0] | select($labels)" <<< "$SELF_HOSTED" 2>/dev/null); then + SELF_HOSTED='["self-hosted"]'; invalid self-hosted +fi +result=$SELF_HOSTED +HOSTED=$(jq -sce "select(length == 1) | .[0] | select($target)" <<< "$HOSTED" 2>/dev/null) || invalid hosted +if [[ -n $OVERFLOW ]]; then OVERFLOW=$(jq -sce "select(length == 1) | .[0] | select($target)" <<< "$OVERFLOW" 2>/dev/null) || invalid overflow; fi +[[ $SOURCE == github || $SOURCE == service ]] || invalid source +[[ $RUNNER_SCOPE == org || $RUNNER_SCOPE == repo ]] || invalid runner-scope +[[ $MIN_IDLE =~ ^[1-9][0-9]{0,5}$ ]] || invalid min-idle +[[ $WEIGHT =~ ^[1-9][0-9]{0,5}$ ]] || invalid weight +choose_overflow() { + if [[ -n $OVERFLOW ]]; then result=$OVERFLOW; choice=overflow; else result=$HOSTED; choice=hosted; fi +} +if [[ $SOURCE == github ]]; then + if [[ -z $TOKEN && -z ${PICKER_FIXTURE_DIR:-} ]]; then fail; fi + repository=${GITHUB_REPOSITORY:-} + [[ $repository =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || invalid GITHUB_REPOSITORY + api=${GITHUB_API_URL:-https://api.github.com} + [[ $api == https://* ]] || invalid GITHUB_API_URL + if [[ $RUNNER_SCOPE == org ]]; then endpoint="orgs/${repository%%/*}"; else endpoint="repos/$repository"; fi + page=1 + while :; do + if [[ -n ${PICKER_FIXTURE_DIR:-} ]]; then + cp "$PICKER_FIXTURE_DIR/page-$page.json" "$work/page.json" + else + curl --silent --fail --connect-timeout 5 --max-time 20 \ + --header "Authorization: Bearer $TOKEN" --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "$api/$endpoint/actions/runners?per_page=100&page=$page" > "$work/page.json" + fi + jq -sce 'select(length == 1) | .[0]' "$work/page.json" > "$work/validated.json" + mv "$work/validated.json" "$work/page.json" + jq -e '(.total_count | type == "number" and . >= 0) and (.runners | type == "array") and all(.runners[]; (.status | type == "string") and (.busy | type == "boolean") and (.labels | type == "array") and all(.labels[]; .name | type == "string"))' "$work/page.json" >/dev/null + jq -c '.runners[]' "$work/page.json" >> "$work/runners.jsonl" + count=$(jq '.runners | length' "$work/page.json") + total=$(jq '.total_count' "$work/page.json") + (( page * 100 >= total )) && break + (( count > 0 && page < 1000 )) || fail + page=$((page + 1)) + done + idle=$(jq -s --argjson labels "$SELF_HOSTED" '[.[] | select(.status == "online" and .busy == false) | ([.labels[].name | ascii_downcase]) as $available | select(all($labels[]; ascii_downcase as $label | $available | index($label)))] | length' "$work/runners.jsonl") + if (( idle >= MIN_IDLE )); then reason='idle runners available'; else choose_overflow; reason='insufficient idle runners'; fi +else + [[ $CAPACITY_URL =~ ^https?://[^[:space:]]+$ && $CAPACITY_URL != *'@'* && $CAPACITY_URL != *'#'* ]] || invalid capacity-url + jq -e 'all(.[]; contains(",") | not)' <<< "$SELF_HOSTED" >/dev/null || invalid self-hosted + proxy=() + if [[ -n $TAILNET_CLIENT_ID ]]; then + [[ ${TAILNET_OUTCOME:-failure} == success ]] || fail + proxy=(--proxy http://localhost:1055 --noproxy '') + fi + if [[ -n ${PICKER_SERVICE_FIXTURE:-} ]]; then + cp "$PICKER_SERVICE_FIXTURE" "$work/service.json" + else + query=(--data-urlencode "labels=$(jq -r 'join(",")' <<< "$SELF_HOSTED")" --data-urlencode "weight=$WEIGHT") + [[ -z ${GITHUB_REPOSITORY:-} ]] || query+=(--data-urlencode "org=${GITHUB_REPOSITORY%%/*}") + [[ -z $OVERFLOW ]] || query+=(--data-urlencode "overflow=$(jq -r 'if type == "array" then join(",") else . end' <<< "$OVERFLOW")") + curl --silent --fail --connect-timeout 5 --max-time 20 "${proxy[@]}" --get "${query[@]}" "$CAPACITY_URL" > "$work/service.json" + fi + jq -sce 'select(length == 1) | .[0]' "$work/service.json" > "$work/validated.json" + mv "$work/validated.json" "$work/service.json" + jq -e 'type == "object" and (.choice == "self-hosted" or .choice == "overflow" or .choice == "hosted") and (.reason | type == "string" and length > 0 and length <= 500 and (test("[\\x00-\\x1f\\x7f%]") | not))' "$work/service.json" >/dev/null + choice=$(jq -r .choice "$work/service.json") + reason=$(jq -r .reason "$work/service.json") + case $choice in + overflow) [[ -n $OVERFLOW ]] || fail; result=$OVERFLOW ;; + hosted) result=$HOSTED ;; + esac +fi +emit diff --git a/tests/request_test.py b/tests/request_test.py new file mode 100644 index 0000000..9725d07 --- /dev/null +++ b/tests/request_test.py @@ -0,0 +1,67 @@ +"""Check request construction and error handling with a fake curl executable.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class RequestsTest(unittest.TestCase): + def run_picker(self, **overrides): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + curl = work / 'curl' + curl.write_text('''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ['REQUEST_LOG'], 'a') as f: + f.write(json.dumps(sys.argv[1:]) + '\\n') +if os.environ.get('FAIL_CURL') == 'yes': + sys.exit(22) +if os.environ.get('SOURCE') == 'service': + print('{"choice":"hosted","reason":"pool busy"}') +else: + print('{"total_count":0,"runners":[]}') +''') + curl.chmod(0o755) + env = {k: v for k, v in os.environ.items() if not k.startswith('PICKER_')} + env.update(PATH=str(work) + os.pathsep + os.environ['PATH'], + GITHUB_REPOSITORY='example-org/example-repo', TOKEN='test-secret-value', + GITHUB_OUTPUT=str(work / 'output'), REQUEST_LOG=str(work / 'requests'), + SELF_HOSTED='["self-hosted","linux"]', HOSTED='"ubuntu-latest"', + OVERFLOW='', SOURCE='github', RUNNER_SCOPE='org', MIN_IDLE='1', WEIGHT='1', + TAILNET_CLIENT_ID='', GITHUB_API_URL='https://api.github.com') + env.update(overrides) + completed = subprocess.run(['bash', 'src/pick.sh'], env=env, text=True, capture_output=True, check=True) + self.assertNotIn('test-secret-value', completed.stdout + completed.stderr) + return (work / 'output').read_text(), [json.loads(line) for line in (work / 'requests').read_text().splitlines()] + + def test_org_api(self): + output, calls = self.run_picker() + self.assertIn('choice=hosted', output) + self.assertIn('https://api.github.com/orgs/example-org/actions/runners?per_page=100&page=1', calls[0]) + self.assertIn('X-GitHub-Api-Version: 2022-11-28', calls[0]) + self.assertIn('Authorization: Bearer test-secret-value', calls[0]) + + def test_repo_api(self): + _, calls = self.run_picker(RUNNER_SCOPE='repo') + self.assertIn('https://api.github.com/repos/example-org/example-repo/actions/runners?per_page=100&page=1', calls[0]) + + def test_http_error(self): + output, _ = self.run_picker(FAIL_CURL='yes') + self.assertIn('choice=self-hosted', output) + self.assertIn('reason=fallback:', output) + + def test_service_contract_and_proxy(self): + output, calls = self.run_picker(SOURCE='service', CAPACITY_URL='https://capacity.example/capacity', + OVERFLOW='"example-overflow"', WEIGHT='2', + TAILNET_CLIENT_ID='example', TAILNET_OUTCOME='success') + self.assertIn('choice=hosted', output) + for value in ['labels=self-hosted,linux', 'weight=2', 'org=example-org', + 'overflow=example-overflow', 'http://localhost:1055']: + self.assertIn(value, calls[0]) + self.assertFalse(any('test-secret-value' in arg for arg in calls[0])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/service_test.py b/tests/service_test.py new file mode 100644 index 0000000..9633f4e --- /dev/null +++ b/tests/service_test.py @@ -0,0 +1,40 @@ +"""Exercise the reference policy without external network calls.""" +import importlib.util +import io +import json +import os +import sys +sys.dont_write_bytecode = True +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("service", "examples/capacity-service/server.py") +service = importlib.util.module_from_spec(spec) +spec.loader.exec_module(service) + + +class PolicyTest(unittest.TestCase): + def test_paginated_case_insensitive_match(self): + pages = [dict(total_count=101, runners=[dict(status="offline", busy=False, labels=[])] * 100), + dict(total_count=101, runners=[dict(status="online", busy=False, labels=[dict(name="LINUX")])])] + with patch.dict(os.environ, RUNNER_OWNER="example-org", RUNNER_TOKEN="test"), patch.object( + service, "urlopen", side_effect=[io.BytesIO(json.dumps(p).encode()) for p in pages] + ) as request: + self.assertEqual(service.decide(["linux"], 1, "", "example-org")["choice"], "self-hosted") + self.assertEqual(request.call_count, 2) + + def test_busy_overflow_and_hosted(self): + for overflow, expected in [("example-overflow", "overflow"), ("", "hosted")]: + with patch.dict(os.environ, RUNNER_OWNER="example-org", RUNNER_TOKEN="test"), patch.object( + service, "urlopen", return_value=io.BytesIO(b'{"total_count":1,"runners":[{"status":"online","busy":true,"labels":[]}]}') + ): + self.assertEqual(service.decide(["linux"], 1, overflow, "")["choice"], expected) + + def test_owner_restriction(self): + with patch.dict(os.environ, RUNNER_OWNER="example-org"): + with self.assertRaises(ValueError): + service.decide(["linux"], 1, "", "other-org") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test.sh b/tests/test.sh new file mode 100644 index 0000000..f3391d0 --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export SELF_HOSTED='["self-hosted","linux"]' GITHUB_REPOSITORY=example-org/example-repo +export PICKER_FIXTURE_DIR="$work" GITHUB_OUTPUT="$work/output" +export SOURCE=github TOKEN='' HOSTED='"ubuntu-latest"' OVERFLOW='' MIN_IDLE=1 WEIGHT=1 RUNNER_SCOPE=org +unset PICKER_SERVICE_FIXTURE TAILNET_CLIENT_ID +runner='{"status":"online","busy":false,"labels":[{"name":"SELF-HOSTED"},{"name":"Linux"}]}' +page() { jq -n --argjson r "$1" '{total_count:($r|length),runners:$r}' > "$work/page-1.json"; } +check() { + : > "$GITHUB_OUTPUT" + bash src/pick.sh > "$work/log" 2>&1 + if ! grep -q "^choice=$2$" "$GITHUB_OUTPUT"; then cat "$work/log" "$GITHUB_OUTPUT"; exit 1; fi + if [[ -n ${3:-} ]]; then grep -q -- "$3" "$work/log"; fi + jq -e . <<< "$(sed -n 's/^runs-on=//p' "$GITHUB_OUTPUT")" >/dev/null + printf 'PASS %s\n' "$1" +} +page "[$runner]"; check 'matching labels and case-insensitivity' self-hosted +page "[$runner]"; MIN_IDLE=2 check 'minimum idle threshold' hosted +page "[$runner]"; sed -i 's/false/true/' "$work/page-1.json"; check busy hosted +page "[$runner]"; sed -i 's/online/offline/' "$work/page-1.json"; check offline hosted +page "[$runner]"; sed -i 's/Linux/windows/' "$work/page-1.json"; check 'missing label' hosted +page '[]'; OVERFLOW='["example-overflow"]' check 'preferred overflow' overflow +check 'hosted fallback' hosted +jq -n '{total_count:101,runners:[range(100)|{status:"offline",busy:false,labels:[]}]}' > "$work/page-1.json" +jq -n --argjson r "$runner" '{total_count:101,runners:[$r]}' > "$work/page-2.json" +check pagination self-hosted +printf 'invalid' > "$work/page-1.json"; check 'bad API JSON' self-hosted +printf '{"message":"denied"}' > "$work/page-1.json"; check 'API error payload' self-hosted +rm "$work/page-1.json"; check 'API transport failure' self-hosted +SELF_HOSTED=invalid check 'bad self-hosted JSON' self-hosted 'invalid self-hosted' +SELF_HOSTED='["self-hosted"] ["linux"]' check 'multiple JSON values' self-hosted 'invalid self-hosted' +HOSTED=invalid check 'bad hosted JSON' self-hosted 'invalid hosted' +OVERFLOW='{}' check 'bad overflow JSON' self-hosted 'invalid overflow' +MIN_IDLE=0 check 'invalid threshold' self-hosted +SOURCE=unknown check 'invalid source' self-hosted +PICKER_FIXTURE_DIR='' check 'missing token' self-hosted +export SOURCE=service CAPACITY_URL=https://capacity.example/capacity PICKER_SERVICE_FIXTURE="$work/service.json" +printf '{"choice":"overflow","reason":"pool busy"}' > "$PICKER_SERVICE_FIXTURE" +OVERFLOW='"example-overflow"' check 'service overflow' overflow +check 'unconfigured service overflow' self-hosted +printf '{"choice":"hosted","reason":"pool busy"}' > "$PICKER_SERVICE_FIXTURE" +check 'service hosted' hosted +printf '{"choice":"self-hosted","reason":"available"}' > "$PICKER_SERVICE_FIXTURE" +check 'service self-hosted' self-hosted +printf '{"choice":"hosted","reason":"bad\noutput"}' > "$PICKER_SERVICE_FIXTURE" +check 'malformed service response' self-hosted +TAILNET_CLIENT_ID=example TAILNET_OUTCOME=failure check 'network setup failure' self-hosted +printf 'All decision tests passed.\n' From c80026bace22ef3d2a18f840bd814a531e545860 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 7 Nov 2026 23:36:47 +0100 Subject: [PATCH 2/2] fix: address authentication, service protocol and release review --- .github/workflows/ci.yml | 3 +- .github/workflows/release.yml | 5 ++ CHANGELOG.md | 6 +++ CONTRIBUTING.md | 4 +- README.md | 10 ++-- action.yml | 1 + examples/capacity-service/README.md | 2 +- examples/capacity-service/server.py | 4 +- scripts/is-latest-release.sh | 10 ++++ src/pick.sh | 24 +++++++--- tests/release_test.py | 42 +++++++++++++++++ tests/request_test.py | 73 +++++++++++++++++++++++++++-- tests/service_test.py | 2 +- tests/test.sh | 2 +- 14 files changed, 165 insertions(+), 23 deletions(-) create mode 100644 scripts/is-latest-release.sh create mode 100644 tests/release_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b73329e..053955d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,8 @@ jobs: bash tests/test.sh python3 tests/service_test.py python3 tests/request_test.py - shellcheck src/*.sh tests/*.sh + python3 tests/release_test.py + shellcheck src/*.sh scripts/*.sh tests/*.sh /tmp/actionlint zizmor --offline . python3 scripts/check-public.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1451c50..f51d0b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,13 @@ jobs: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f81c0ed..9cbf1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 446936c..eb8ccdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,6 @@ 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`, `shellcheck src/*.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. +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 to that exact commit. Review major-tag changes before use; consumers should pin a full commit SHA. +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. diff --git a/README.md b/README.md index 14af7a1..0248254 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Use the same two-job structure, replacing the picker step with: 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`. 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. +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/`; 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 @@ -63,10 +63,10 @@ The action performs a GET on `capacity-url` with URL-encoded parameters: | --- | --- | | `labels` | Required self-hosted labels, comma-separated | | `weight` | Positive integer request weight | -| `org` | Optional caller repository owner | -| `overflow` | Optional alternate label, or comma-separated labels for an array | +| `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"}`. Valid choices are `self-hosted`, `overflow`, and `hosted`. `overflow` selects the configured overflow input; selecting it without an input is an error. `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, non-success HTTP status 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. +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 @@ -84,7 +84,7 @@ All runs-on inputs are JSON, including strings: use `'"ubuntu-latest"'`, not `'u | `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 | +| `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"]`. diff --git a/action.yml b/action.yml index 81c05ee..5f4cf0e 100644 --- a/action.yml +++ b/action.yml @@ -68,6 +68,7 @@ runs: 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' diff --git a/examples/capacity-service/README.md b/examples/capacity-service/README.md index 2fb34ac..eaa95f3 100644 --- a/examples/capacity-service/README.md +++ b/examples/capacity-service/README.md @@ -11,4 +11,4 @@ python3 examples/capacity-service/server.py `RUNNER_OWNER` is an organization for `org`, or `owner/repository` for `repo`. `RUNNER_TOKEN` needs runner read access. `BIND` defaults to `localhost`; `PORT` defaults to `8080`. -GET `/capacity?labels=self-hosted,linux&weight=1&org=example-org&overflow=example-overflow` returns `{"choice":"self-hosted","reason":"idle runners available"}` or an overflow decision. Labels are comma-separated and matched without case sensitivity. Weight is the required number of idle runners. Optional `org` is checked against the configured owner; it cannot redirect credential use. Optional `overflow` indicates that an alternate target exists. Without it, insufficient capacity returns `hosted`. Errors return HTTP 503 so the action falls back to self-hosted. API calls paginate with 100 runners per page and use API version 2022-11-28. +GET `/capacity?labels=self-hosted,linux&weight=1&org=example-org&blacksmith=example-overflow` returns `{"choice":"self-hosted","reason":"idle runners available"}` or an overflow decision. Labels are comma-separated and matched without case sensitivity. Weight is the required number of idle runners. `org` is sent only with an overflow offer. Optional `org` is checked against the configured owner; it cannot redirect credential use. Optional `blacksmith` indicates that an alternate target exists. Insufficient capacity with that offer returns wire choice `blacksmith`; the action translates it to public output choice `overflow`. Without it, insufficient capacity returns `hosted`. Errors return HTTP 503 so the action falls back to self-hosted. API calls paginate with 100 runners per page and use API version 2022-11-28. diff --git a/examples/capacity-service/server.py b/examples/capacity-service/server.py index 9e9c08c..6c64165 100644 --- a/examples/capacity-service/server.py +++ b/examples/capacity-service/server.py @@ -37,7 +37,7 @@ def decide(labels, weight, overflow, org): if not runners or page >= 1000: raise ValueError("incomplete runner listing") page += 1 - return {"choice": "self-hosted" if idle >= weight else "overflow" if overflow else "hosted", + return {"choice": "self-hosted" if idle >= weight else "blacksmith" if overflow else "hosted", "reason": "idle runners available" if idle >= weight else "insufficient idle runners"} @@ -56,7 +56,7 @@ def do_GET(self): weight = int(query.get("weight", ["1"])[0]) if not all(labels) or not 1 <= weight <= 999999: raise ValueError("invalid request") - result = decide(labels, weight, query.get("overflow", [""])[0], query.get("org", [""])[0]) + result = decide(labels, weight, query.get("blacksmith", [""])[0], query.get("org", [""])[0]) body = json.dumps(result).encode() self.send_response(200) self.send_header("Content-Type", "application/json") diff --git a/scripts/is-latest-release.sh b/scripts/is-latest-release.sh new file mode 100644 index 0000000..a47ee33 --- /dev/null +++ b/scripts/is-latest-release.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Advance a major tag only to its highest stable semantic version. +set -euo pipefail +tag=${1:-} +version='(0|[1-9][0-9]*)' +[[ $tag =~ ^v${version}\.${version}\.${version}$ ]] || exit 1 +major=${tag%%.*} +tags=$(git tag --list "$major.*.*") +latest=$(printf '%s\n' "$tags" | grep -E "^${major}\\.${version}\\.${version}$" | sort -V | tail -n 1) +[[ $tag == "$latest" ]] diff --git a/src/pick.sh b/src/pick.sh index 5593cf9..f5c5c28 100644 --- a/src/pick.sh +++ b/src/pick.sh @@ -42,6 +42,13 @@ if [[ -n $OVERFLOW ]]; then OVERFLOW=$(jq -sce "select(length == 1) | .[0] | sel [[ $RUNNER_SCOPE == org || $RUNNER_SCOPE == repo ]] || invalid runner-scope [[ $MIN_IDLE =~ ^[1-9][0-9]{0,5}$ ]] || invalid min-idle [[ $WEIGHT =~ ^[1-9][0-9]{0,5}$ ]] || invalid weight +http_get() { + local destination=$1 status + shift + status=$(curl --silent --fail --connect-timeout 5 --max-time 20 \ + --output "$destination" --write-out '%{http_code}' "$@") || return 1 + [[ $status == 200 ]] +} choose_overflow() { if [[ -n $OVERFLOW ]]; then result=$OVERFLOW; choice=overflow; else result=$HOSTED; choice=hosted; fi } @@ -57,10 +64,10 @@ if [[ $SOURCE == github ]]; then if [[ -n ${PICKER_FIXTURE_DIR:-} ]]; then cp "$PICKER_FIXTURE_DIR/page-$page.json" "$work/page.json" else - curl --silent --fail --connect-timeout 5 --max-time 20 \ + http_get "$work/page.json" \ --header "Authorization: Bearer $TOKEN" --header 'Accept: application/vnd.github+json' \ --header 'X-GitHub-Api-Version: 2022-11-28' \ - "$api/$endpoint/actions/runners?per_page=100&page=$page" > "$work/page.json" + "$api/$endpoint/actions/runners?per_page=100&page=$page" || fail fi jq -sce 'select(length == 1) | .[0]' "$work/page.json" > "$work/validated.json" mv "$work/validated.json" "$work/page.json" @@ -86,17 +93,20 @@ else cp "$PICKER_SERVICE_FIXTURE" "$work/service.json" else query=(--data-urlencode "labels=$(jq -r 'join(",")' <<< "$SELF_HOSTED")" --data-urlencode "weight=$WEIGHT") - [[ -z ${GITHUB_REPOSITORY:-} ]] || query+=(--data-urlencode "org=${GITHUB_REPOSITORY%%/*}") - [[ -z $OVERFLOW ]] || query+=(--data-urlencode "overflow=$(jq -r 'if type == "array" then join(",") else . end' <<< "$OVERFLOW")") - curl --silent --fail --connect-timeout 5 --max-time 20 "${proxy[@]}" --get "${query[@]}" "$CAPACITY_URL" > "$work/service.json" + if [[ -n $OVERFLOW ]]; then + repository=${GITHUB_REPOSITORY:-} + query+=(--data-urlencode "org=${repository%%/*}" + --data-urlencode "blacksmith=$(jq -r 'if type == "array" then .[0] else . end' <<< "$OVERFLOW")") + fi + http_get "$work/service.json" "${proxy[@]}" --get "${query[@]}" "$CAPACITY_URL" || fail fi jq -sce 'select(length == 1) | .[0]' "$work/service.json" > "$work/validated.json" mv "$work/validated.json" "$work/service.json" - jq -e 'type == "object" and (.choice == "self-hosted" or .choice == "overflow" or .choice == "hosted") and (.reason | type == "string" and length > 0 and length <= 500 and (test("[\\x00-\\x1f\\x7f%]") | not))' "$work/service.json" >/dev/null + jq -e 'type == "object" and (.choice == "self-hosted" or .choice == "blacksmith" or .choice == "hosted") and (.reason | type == "string" and length > 0 and length <= 500 and (test("[\\x00-\\x1f\\x7f%]") | not))' "$work/service.json" >/dev/null choice=$(jq -r .choice "$work/service.json") reason=$(jq -r .reason "$work/service.json") case $choice in - overflow) [[ -n $OVERFLOW ]] || fail; result=$OVERFLOW ;; + blacksmith) [[ -n $OVERFLOW ]] || fail; result=$OVERFLOW; choice=overflow ;; hosted) result=$HOSTED ;; esac fi diff --git a/tests/release_test.py b/tests/release_test.py new file mode 100644 index 0000000..5c770db --- /dev/null +++ b/tests/release_test.py @@ -0,0 +1,42 @@ +"""Check major-tag advancement against real local Git tag sets.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = str(Path('scripts/is-latest-release.sh').resolve()) + + +class ReleaseTest(unittest.TestCase): + def test_highest_version_only(self): + with tempfile.TemporaryDirectory() as directory: + def git(*args): + return subprocess.check_output(['git', '-C', directory, *args], text=True).strip() + git('init', '--quiet') + env = dict(os.environ, GIT_AUTHOR_NAME='Rhuan Barreto', GIT_AUTHOR_EMAIL='rhuan@barreto.work', + GIT_COMMITTER_NAME='Rhuan Barreto', GIT_COMMITTER_EMAIL='rhuan@barreto.work') + tree = subprocess.check_output(['git', '-C', directory, 'mktree'], input='', text=True).strip() + commit = subprocess.check_output(['git', '-C', directory, 'commit-tree', tree, '-m', 'test'], env=env, text=True).strip() + for tag in ['v1.2.9', 'v1.2.10', 'v2.0.0', 'v1.4.0-rc.1', 'v1']: + git('tag', tag, commit) + for tag, expected in [('v1.2.9', 1), ('v1.2.10', 0), ('v2.0.0', 0), + ('v1.4.0-rc.1', 1), ('v1', 1)]: + with self.subTest(tag=tag): + result = subprocess.run(['bash', SCRIPT, tag], cwd=directory, capture_output=True) + self.assertEqual(result.returncode, expected, result.stderr) + for tag in ['v1.3.0', 'v1.10.0']: + git('tag', tag, commit) + for tag, expected in [('v1.2.10', 1), ('v1.3.0', 1), ('v1.10.0', 0)]: + with self.subTest(tag=tag): + result = subprocess.run(['bash', SCRIPT, tag], cwd=directory, capture_output=True) + self.assertEqual(result.returncode, expected, result.stderr) + + def test_release_workflow_checks_before_moving(self): + workflow = Path('.github/workflows/release.yml').read_text() + self.assertLess(workflow.index('bash scripts/is-latest-release.sh "$TAG"'), + workflow.index('gh api --method PATCH')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/request_test.py b/tests/request_test.py index 9725d07..6791086 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -5,6 +5,8 @@ import subprocess import tempfile import unittest +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class RequestsTest(unittest.TestCase): @@ -19,9 +21,15 @@ def run_picker(self, **overrides): if os.environ.get('FAIL_CURL') == 'yes': sys.exit(22) if os.environ.get('SOURCE') == 'service': - print('{"choice":"hosted","reason":"pool busy"}') + body = os.environ.get('SERVICE_RESPONSE','{"choice":"hosted","reason":"pool busy"}') else: - print('{"total_count":0,"runners":[]}') + body = '{"total_count":0,"runners":[]}' +if '--output' in sys.argv: + with open(sys.argv[sys.argv.index('--output') + 1], 'w') as f: + f.write(body) + print(os.environ.get('HTTP_STATUS', '200'), end='') +else: + print(body) ''') curl.chmod(0o755) env = {k: v for k, v in os.environ.items() if not k.startswith('PICKER_')} @@ -36,6 +44,65 @@ def run_picker(self, **overrides): self.assertNotIn('test-secret-value', completed.stdout + completed.stderr) return (work / 'output').read_text(), [json.loads(line) for line in (work / 'requests').read_text().splitlines()] + def test_federated_audience(self): + action = Path('action.yml').read_text() + self.assertIn('audience: api.tailscale.com/${{ inputs.tailnet-client-id }}', action) + + def test_service_provider_response(self): + output, calls = self.run_picker(SOURCE='service', CAPACITY_URL='https://capacity.example/capacity', + OVERFLOW='["example-overflow","linux"]', + SERVICE_RESPONSE='{"choice":"blacksmith","reason":"capacity available"}') + self.assertIn('choice=overflow', output) + self.assertIn('runs-on=["example-overflow","linux"]', output) + self.assertIn('blacksmith=example-overflow', calls[0]) + self.assertIn('org=example-org', calls[0]) + self.assertNotIn('overflow=example-overflow,linux', calls[0]) + + def test_no_provider_offer_omits_org(self): + _, calls = self.run_picker(SOURCE='service', CAPACITY_URL='https://capacity.example/capacity') + self.assertFalse(any(arg.startswith(('org=', 'blacksmith=')) for arg in calls[0])) + + def test_non_200_falls_back(self): + for source in ['github', 'service']: + for status in ['201', '204', '302', '401', '500']: + with self.subTest(source=source, status=status): + output, _ = self.run_picker(SOURCE=source, HTTP_STATUS=status, + CAPACITY_URL='https://capacity.example/capacity') + self.assertIn('choice=self-hosted', output) + self.assertIn('reason=fallback:', output) + + def test_real_http_redirect_is_not_a_decision(self): + class Handler(BaseHTTPRequestHandler): + status = 302 + + def log_message(self, *_args): + pass + + def do_GET(self): + self.send_response(self.status) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"choice":"hosted","reason":"redirected"}') + + with ThreadingHTTPServer(('localhost', 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as directory: + env = {k: v for k, v in os.environ.items() if not k.startswith('PICKER_')} + env.update(SOURCE='service', TAILNET_CLIENT_ID='', OVERFLOW='', + SELF_HOSTED='["self-hosted"]', HOSTED='"ubuntu-latest"', + CAPACITY_URL=f'http://localhost:{server.server_port}/capacity', + GITHUB_OUTPUT=directory + '/output') + for status, expected in [(200, 'hosted'), (302, 'self-hosted')]: + Handler.status = status + Path(env['GITHUB_OUTPUT']).write_text('') + subprocess.run(['bash', 'src/pick.sh'], env=env, capture_output=True, check=True) + self.assertIn('choice=' + expected, Path(env['GITHUB_OUTPUT']).read_text()) + finally: + server.shutdown() + thread.join() + def test_org_api(self): output, calls = self.run_picker() self.assertIn('choice=hosted', output) @@ -58,7 +125,7 @@ def test_service_contract_and_proxy(self): TAILNET_CLIENT_ID='example', TAILNET_OUTCOME='success') self.assertIn('choice=hosted', output) for value in ['labels=self-hosted,linux', 'weight=2', 'org=example-org', - 'overflow=example-overflow', 'http://localhost:1055']: + 'blacksmith=example-overflow', 'http://localhost:1055']: self.assertIn(value, calls[0]) self.assertFalse(any('test-secret-value' in arg for arg in calls[0])) diff --git a/tests/service_test.py b/tests/service_test.py index 9633f4e..9ee6d42 100644 --- a/tests/service_test.py +++ b/tests/service_test.py @@ -24,7 +24,7 @@ def test_paginated_case_insensitive_match(self): self.assertEqual(request.call_count, 2) def test_busy_overflow_and_hosted(self): - for overflow, expected in [("example-overflow", "overflow"), ("", "hosted")]: + for overflow, expected in [("example-overflow", "blacksmith"), ("", "hosted")]: with patch.dict(os.environ, RUNNER_OWNER="example-org", RUNNER_TOKEN="test"), patch.object( service, "urlopen", return_value=io.BytesIO(b'{"total_count":1,"runners":[{"status":"online","busy":true,"labels":[]}]}') ): diff --git a/tests/test.sh b/tests/test.sh index f3391d0..3a355d6 100644 --- a/tests/test.sh +++ b/tests/test.sh @@ -38,7 +38,7 @@ MIN_IDLE=0 check 'invalid threshold' self-hosted SOURCE=unknown check 'invalid source' self-hosted PICKER_FIXTURE_DIR='' check 'missing token' self-hosted export SOURCE=service CAPACITY_URL=https://capacity.example/capacity PICKER_SERVICE_FIXTURE="$work/service.json" -printf '{"choice":"overflow","reason":"pool busy"}' > "$PICKER_SERVICE_FIXTURE" +printf '{"choice":"blacksmith","reason":"pool busy"}' > "$PICKER_SERVICE_FIXTURE" OVERFLOW='"example-overflow"' check 'service overflow' overflow check 'unconfigured service overflow' self-hosted printf '{"choice":"hosted","reason":"pool busy"}' > "$PICKER_SERVICE_FIXTURE"