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
122 changes: 122 additions & 0 deletions .github/scripts/tests/test_select_runner_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Keep a candidate-image wait off the time-limited light runner pool.

`runner-image.py select` can wait up to two hours for a PR's candidate image,
longer than a dash-x64-light job may run. A selector job may use the light
pool only through the `runner-image-changed` guard, which every path that
might wait turns into a GitHub-hosted runner.
"""
import importlib.util
from pathlib import Path
import re
import unittest


ROOT = Path(__file__).resolve().parents[3]
WORKFLOWS = ROOT / ".github/workflows"
spec = importlib.util.spec_from_file_location("routing_runner_image", ROOT / ".github/scripts/runner-image.py")
runner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runner)

GUARDED = "${{ !inputs.runner-image-changed && vars.DASH_LIGHT_RUNNER || 'ubuntu-24.04' }}"
FAIL_SAFE = "${{ needs.changes.outputs.runner-image-changed != 'false' }}"


def children(lines, indent):
"""Map each mapping key at `indent` spaces to the lines nested under it."""
found, key = {}, None
for line in lines:
depth = len(line) - len(line.lstrip(" "))
match = re.fullmatch(r"([\w-]+):.*", line[indent:]) if depth == indent else None
if match:
key = match.group(1)
found[key] = []
elif key and line.strip() and depth > indent:
found[key].append(line)
return found


def scalar(lines, indent, key):
"""Return the inline value of `key` at `indent` spaces, or None."""
for line in lines:
match = re.fullmatch(" " * indent + re.escape(key) + r":\s*(.*?)\s*", line)
if match:
return match.group(1)
return None


def load(path):
"""Read a workflow, without comment lines, as its top-level blocks."""
lines = [line for line in path.read_text().splitlines() if not line.lstrip().startswith("#")]
return children(lines, 0)


def jobs(path):
return children(load(path).get("jobs", []), 2)


def selector_jobs():
"""Yield (workflow, job name, job lines) for every runner-image selector."""
for path in sorted(WORKFLOWS.glob("*.yml")):
for name, lines in jobs(path).items():
if any("runner-image.py select" in line for line in lines):
yield path, name, lines


def guarded_workflows():
return sorted({path for path, _, lines in selector_jobs() if scalar(lines, 4, "runs-on") == GUARDED})


class SelectRunnerRoutingTests(unittest.TestCase):
def test_should_keep_every_selector_hosted_unless_guarded(self):
"""A selector reaches the light pool only through the runner-image guard."""
for path, name, lines in selector_jobs():
with self.subTest(workflow=path.name, job=name):
runs_on = scalar(lines, 4, "runs-on") or ""
self.assertTrue(runs_on == GUARDED or re.fullmatch(r"ubuntu-[\w.]+", runs_on), runs_on)
self.assertLessEqual({"tests-rs-workspace.yml", "tests-rs-wallet.yml"},
{path.name for path in guarded_workflows()})

def test_should_default_the_guard_to_a_hosted_runner(self):
"""A caller that does not pass the flag gets a hosted selector."""
for path in guarded_workflows():
with self.subTest(workflow=path.name):
call = children(children(load(path)["on"], 2)["workflow_call"], 4)
guard = children(call["inputs"], 6)["runner-image-changed"]
self.assertEqual(scalar(guard, 8, "type"), "boolean")
self.assertEqual(scalar(guard, 8, "default"), "true")

def test_should_flag_exactly_the_manifest_the_selector_waits_on(self):
"""The changes job reports true for any PR that edits the AMD64 manifest."""
changes = jobs(WORKFLOWS / "tests.yml")["changes"]
outputs = children(changes, 4)["outputs"]
self.assertEqual(scalar(outputs, 6, "runner-image-changed"),
"${{ steps.filter-rs-workflows.outputs.runner-image }}")
text = "\n".join(changes) + "\n"
step = re.split(r"\n {6}- ", text[text.index("id: filter-rs-workflows"):])[0] + "\n"
paths = re.search(r"^( +)runner-image:\n((?:\1 +- .*\n)+)", step, re.MULTILINE)
self.assertIsNotNone(paths)
self.assertEqual(re.findall(r"- (\S+)", paths.group(2)), [runner.MANIFEST])

def test_should_pass_the_guard_fail_safe_from_every_caller(self):
"""Only an explicit 'false' from the changes job puts an AMD64 selector on the light pool."""
guarded = {path.name for path in guarded_workflows()}
callers = set()
for path in sorted(WORKFLOWS.glob("*.yml")):
for name, lines in jobs(path).items():
uses = scalar(lines, 4, "uses") or ""
if not uses.startswith("./.github/workflows/") or Path(uses).name not in guarded:
continue
callers.add(name)
with self.subTest(workflow=path.name, job=name):
inputs = children(lines, 4).get("with", [])
allowed = [None, "true", FAIL_SAFE]
if scalar(inputs, 6, "runner-architecture") == "ARM64":
# An ARM64 selection returns before the AMD64 candidate
# wait (see test_runner_image's ARM64 tests).
allowed.append("false")
self.assertIn(scalar(inputs, 6, "runner-image-changed"), allowed)
self.assertLessEqual({"rs-workspace-tests", "rs-arm64-image-tests", "rs-wallet-tests"}, callers)


if __name__ == "__main__":
unittest.main()
10 changes: 9 additions & 1 deletion .github/workflows/tests-rs-wallet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ on:
description: Whether doc comments with code examples have changed
type: boolean
default: false
runner-image-changed:
# Same as in tests-rs-workspace.yml: true keeps select-runner on a
# GitHub-hosted runner, where it may wait for a candidate image.
description: Whether the pull request changes the AMD64 runner requirements
type: boolean
default: true

jobs:
select-runner:
Expand All @@ -39,7 +45,9 @@ jobs:
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository
|| github.event.pull_request.head.repo.owner.login == 'thepastaclaw'
runs-on: ubuntu-24.04
# Light pool unless this job may wait for a candidate image; see
# tests-rs-workspace.yml.
runs-on: ${{ !inputs.runner-image-changed && vars.DASH_LIGHT_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 135
permissions:
contents: read
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/tests-rs-workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ on:
description: Use validation-only ARM64 capacity before admission to the ordinary Rust pool
type: boolean
default: false
runner-image-changed:
# Computed by the `changes` job in tests.yml. Only a PR that edits
# .github/runner-requirements.json makes select-runner wait for a
# candidate image, which can outlast a light-pool job's time limit.
# Defaults to true so a caller that doesn't compute it keeps
# select-runner on a GitHub-hosted runner.
description: Whether the pull request changes the AMD64 runner requirements
type: boolean
default: true
Comment thread
QuantumExplorer marked this conversation as resolved.
Resolved
doctests-changed:
description: Whether doc comments with code examples have changed
type: boolean
Expand Down Expand Up @@ -40,7 +49,11 @@ jobs:
github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository
|| github.event.pull_request.head.repo.owner.login == 'thepastaclaw'
runs-on: ubuntu-24.04
# The Rust tests cannot start until this job finishes. On the light pool it
# does not queue for a GitHub-hosted slot, which every repository in the
# organization shares. Waiting for a candidate image stays on a hosted
# runner, since it can run longer than the light pool allows.
runs-on: ${{ !inputs.runner-image-changed && vars.DASH_LIGHT_RUNNER || 'ubuntu-24.04' }}
# Allow the separate 90-minute build and publication/queue window to finish.
timeout-minutes: 135
permissions:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
rs-packages: ${{ steps.override.outputs.rs-packages || steps.filter-rs.outputs.changes }}
rs-workflows-changed: ${{ steps.filter-rs-workflows.outputs.rs-workflows }}
arm64-image-changed: ${{ steps.filter-rs-workflows.outputs.arm64-image }}
runner-image-changed: ${{ steps.filter-rs-workflows.outputs.runner-image }}
rs-scope: ${{ steps.rs-scope.outputs.scope }}
shielded-changed: ${{ steps.override.outputs.shielded-changed || steps.filter-shielded.outputs.shielded-changed }}
doctests-changed: ${{ steps.override.outputs.doctests-changed || steps.filter-doctests.outputs.doctests-changed }}
Expand Down Expand Up @@ -119,6 +120,8 @@ jobs:
- .github/actions/rust/**
arm64-image:
- .github/runner-requirements.arm64.json
runner-image:
- .github/runner-requirements.json

- uses: dorny/paths-filter@v4
id: filter-e2e
Expand Down Expand Up @@ -492,6 +495,9 @@ jobs:
# Anything other than an explicit 'false' (including an empty output
# from a skipped detector step) fails safe into running the suite.
shielded-changed: ${{ needs.changes.outputs.shielded-changed != 'false' }}
# Same fail-safe: anything but an explicit 'false' keeps runner
# selection on a GitHub-hosted runner, where it may wait for a candidate.
runner-image-changed: ${{ needs.changes.outputs.runner-image-changed != 'false' }}
# Code coverage runs nightly only. PR and push runs execute the same
# test phases without instrumentation and upload nothing to Codecov.
coverage: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.coverage == true) }}
Expand All @@ -509,6 +515,8 @@ jobs:
with:
runner-architecture: ARM64
validate-arm64-image: true
# ARM64 selection never waits for a candidate image.
runner-image-changed: false
doctests-changed: true
shielded-changed: true
coverage: false
Expand All @@ -525,6 +533,7 @@ jobs:
uses: ./.github/workflows/tests-rs-wallet.yml
with:
doctests-changed: ${{ needs.changes.outputs.doctests-changed == 'true' }}
runner-image-changed: ${{ needs.changes.outputs.runner-image-changed != 'false' }}

swift-sdk-build:
name: Swift SDK build
Expand Down
Loading