Skip to content

Commit 8bdc2df

Browse files
[AI-6827] Add ddev release test-agent command (DataDog#23722)
* Add ddev release test-agent command Dispatch both .github/workflows/test-agent.yml and test-agent-windows.yml against a release branch or tag, with Agent image resolution and validation against registry.datadoghq.com. The command resolves the latest published *-rc.N tag for a branch input, validates Linux and Windows (servercore) manifests, shows a confirmation panel, then fires both workflow_dispatch calls in parallel via the async GitHub client. Extends AsyncGitHubClient.create_workflow_dispatch with a typed return_run_details kwarg (overloads) so the new 200 response shape (workflow_run_id, run_url, html_url) is parsed into a WorkflowDispatchResult when requested, and the result panel links directly to each run. * Add changelog entry * Apply review feedback: tighten typing, broaden registry handling, expand tests - Rename _MANIFEST_ACCEPT to MANIFEST_ACCEPT per AGENTS.md (no underscore prefix on module constants). - Move asyncio import to module level; drop duplicate import. - Narrow _extract_run_urls signature to Sequence[GitHubResponse[WorkflowDispatchResult] | BaseException]; remove three type:ignore comments and the unused owner/repo/ref params. - Surface the originating exception (__cause__) in the abort message after a dispatch failure. - Combine messages when both Linux and Windows dispatches fail. - tag.lstrip('v') -> tag.removeprefix('v') for accurate intent. - Document REPO_OWNER design choice with a short comment. - Add one-line docstrings to manifest_url and tags_list_url. - Request a large page (n=10000) when listing registry tags; the Agent registry has many years of tags and the default page may not include the current release cycle. - Type-annotate test fixtures and test functions. - Parametrize test_branch_resolves_latest_rc over workflow_id; add a mirror test for the Linux-fails partial-dispatch case; add a both-fail case; cover 401/403/503 in the manifest error test; cover null and missing 'tags' key in the tags-list parser. * Apply round-2 review feedback + ruff 0.11.10 format Review feedback: - Pass inputs dict through to _print_plan so the plan display is derived from the same source the dispatch sees (no more hardcoded 'true'/'false' drifting from the inputs values). - Use string values ('true', 'false') for type:boolean workflow inputs to match what the GitHub workflow_dispatch API documents. - Replace assert-based type narrowing in _extract_run_urls with nested isinstance checks so the flow stays sound under python -O. - Rename surviving_label -> failing_label in the parametrized partial-dispatch test (the assertion targets the failing side's message, not the surviving). CI fix: - Reformat with ruff 0.11.10 (the version pinned by the ddev CI workflow); my local hatch env shipped 0.15.11 which produced minor multi-line layout differences. Affects test-agent.yml lint for both Linux and Windows matrices. * Harden ddev release test-agent error handling - Abort early when github.token is empty instead of leaking the AsyncGitHubClient ValueError out of asyncio.run. - Abort with a friendly message when the public Agent registry returns a non-404 HTTP error from manifest_exists or list_agent_rc_tags, rather than surfacing the raw httpx traceback. - Narrow the inputs dict to dict[str, str] all the way through _dispatch_both / _dispatch_both_async; the workflow_dispatch API rejects non-string values, so an accidental non-string value now becomes a type error at the closest boundary to the API call. - Drop the cause-formatting suffix on the dispatch-failure abort; the RuntimeError messages already embed the underlying error so the cause would render twice. - Drop the unused ref parameter from _print_result. - Add tests covering timeout and connection-error propagation from the registry helpers. * Hardcode dispatch target to DataDog/integrations-core - Introduce REPO_NAME='integrations-core' constant alongside REPO_OWNER and use it in the workflow_dispatch call, so a user with ddev pointed at integrations-extras/marketplace/a fork doesn't silently dispatch to DataDog/<wrong-repo>. Both test-agent.yml workflows only exist on integrations-core, matching the existing owner hardcoding rationale. - Lift the duplicate inline 'import httpx' to a top-level import so the module's external dependencies are visible at a glance. - Add tests for the empty-token guard and for the two registry HTTP error paths that translate to friendly app.abort messages. * Extract docker_registry utility and tighten test-agent workflow checks - Move generic Docker Registry v2 helpers (manifest probe, tag listing with Link: rel=next pagination) into ddev.utils.docker_registry so they can be reused by other release tooling. The agent-specific RC filter stays in cli.release.test_agent.registry as a thin wrapper. - Read workflow files from origin/<branch> so a branch the user has not yet fetched no longer reads as a missing workflow file. Distinguish "file not in tree" from "ref not in local clone" from other git failures so the abort message points at the real problem. - Move httpx and asyncio imports back inside the functions that use them; the registry module is lazy-imported, so the top-level imports were paid on every ddev invocation (including ddev --help). - Use add_note to keep the Windows traceback attached when both dispatches fail. Wrap the result print in try/except/else so the success-only call site is obvious. - Take versions (not full image refs) into _validate_images_exist; drop the rsplit round-trip. - Note in the inputs dict that test-py2='false' on Windows is intentional. * Lift asyncio import to module top * Surface both errors when test-agent dispatches both fail - Fold the Linux and Windows error reprs into the RuntimeError message so app.abort(str(e)) renders both. The previous add_note(...) approach stored the Windows error in __notes__, which str(exc) does not include, so the Windows side was silently dropped from the abort output. - Re-raise CancelledError and other non-Exception BaseException subclasses from _extract_run_urls before treating results as dispatch failures. asyncio.gather(return_exceptions=True) captures cancellation into the result list; wrapping it in RuntimeError would hide flow-control intent. - Pass the github token directly into _dispatch_both instead of threading the whole Application object through. Decouples the helper. - Centralize the httpx.HTTPError -> app.abort translation in a _registry_errors context manager; removes the duplicated try/except in _resolve_version and _validate_images_exist and keeps the lazy httpx import in one place. - Render the dispatch plan via display_info so it lands on stderr alongside the surrounding progress lines; piping the command no longer splits the pre-dispatch narrative across stdout and stderr. - Replace the hand-rolled Link-header parser in docker_registry.list_tags with httpx.Response.links, which handles RFC 5988 quoting and multi-link rels correctly. - Strengthen test_both_dispatches_fail_combine_messages to assert both Windows: and a count of 2 of the shared error repr so the regression cannot recur. * Split test-agent helpers into validation/images/dispatch modules Move the supporting logic for `ddev release test-agent` into sibling modules so the command file reads as the orchestration story it tells: - validation.py — input regex checks, git ref existence on origin, and workflow-file presence on the resolved ref (including the file-missing vs ref-not-fetched vs unknown-git-failure dispatch in the error path). - images.py — RC version resolution, image ref construction, manifest existence checks, and the registry_errors context manager that translates httpx errors into clean abort messages. - dispatch.py — the parallel workflow_dispatch orchestration. The async coroutine is nested inside dispatch_both so the reader sees the full flow in one function rather than bouncing between two near-empty stack frames. extract_run_urls keeps the partial/total-failure surface next to the dispatch. `__init__.py` now contains only the Click command plus the small `_print_plan`/`_print_result` display helpers. Every sibling module is imported lazily inside the command body so `ddev --help` only pays for `click` from this package. Also narrow `AsyncGitHubClient.create_workflow_dispatch`'s `inputs` parameter from `dict[str, Any] | None` to `dict[str, str] | None` across both `@overload`s and the implementation. The workflow_dispatch API contract is string-to-string (booleans are matched against the lowercase string form), every in-tree caller already passes a `dict[str, str]`, and the wider type silently accepted values that would surface as runtime 422s from GitHub. The fake test client mirror is updated to match. * Auto-fetch the target ref and model branch/tag as a sum type Make the user's `--branch` or `--tag` choice a typed `Branch | Tag` produced by `validate_input`, and have every downstream helper take that `ReleaseTarget` instead of `(branch: str | None, tag: str | None)`. This puts the "exactly one is set" invariant in the type system and removes the type-narrowing `assert`s that would otherwise turn into an `AssertionError` with no context if anyone broke the invariant. While at it, drop the `git ls-remote` probe + "please run `git fetch` and try again" hint and fetch the ref ourselves. The new `fetch_target` runs `git fetch --quiet --depth=1 origin refs/heads/<branch>:refs/remotes/origin/<branch>` (or the equivalent `refs/tags/...:refs/tags/...` for `--tag`), which both confirms the ref exists on origin and populates the local refs we need to read the workflow files. If `git fetch` reports `couldn't find remote ref`, the abort message stays the same as before (`Branch X not found on origin`); other git errors surface verbatim through `Failed to fetch ... from origin: ...`. Tests now mock `GitRepository.run` (the fetch) instead of `GitRepository.capture` (the ls-remote). Two new tests pin the exact refspec the command must send so a future refactor that breaks the fetch shape can't slip through. The "please-fetch-first" test goes away; that branch is unreachable now. * Define workflow names in validation; symmetric error shape; cover CancelledError - Move WORKFLOW_LINUX/WORKFLOW_WINDOWS from dispatch.py to validation.py and have dispatch.py import them from validation. Inverts the previous arrow so the layer that runs first owns the constants; validation no longer pulls in dispatch's async/HTTP modules at import time. - Make fetch_target's OSError handler use `if/else` for symmetry with verify_workflows_present_on_ref. Behavior unchanged; the two helpers now read identically instead of relying on `app.abort`'s `NoReturn` to keep the trailing abort unreachable. - Add direct unit tests for extract_run_urls. The existing test_command tests only feed httpx.HTTPStatusError (an Exception), so the BaseException-but-not-Exception re-raise that lets CancelledError / KeyboardInterrupt propagate was untested in CI. New tests pin both the flow-control propagation contract and the both-failure message shape. * Render dispatch result as a panel and space out the command's output blocks - Add blank-line separators between each phase of the command (fetch, version resolution, image validation, dispatch plan, final result) so the output reads as distinct sections rather than one continuous stream. - Replace the trailing `display_success` + two `display_pair` calls with a rich Panel matching the look of `ddev release port-commit`'s completion summary. The two run URLs sit inside a cyan-bordered "Workflows dispatched" panel with bold-aligned Linux/Windows labels. * Allow forced test-agent branch fetch * Add test-agent workflow monitoring * Fix mypy errors in test-agent module - dispatch.py: declare DispatchOutcome as a PEP 695 type alias so mypy recognises it as a type (the previous TYPE_CHECKING-guarded assignment read as a variable on mypy 2.1). - validation.py: validate both branch/tag invariants up front and assert the remaining one is set, removing the trailing app.abort that mypy refused to credit as a function terminator. * Match all patch RCs when resolving --branch in test-agent * Tighten test-agent types and harden error paths - Move REPO_OWNER/REPO_NAME from dispatch.py to validation.py so monitoring no longer pulls dispatch's async/HTTP module just for two strings. - Narrow monitor_workflows / monitor_dispatched_workflows first parameter from Application to Terminal — matches what the body actually uses and what the tests already pass. - Tighten extract_dispatched_workflows input to tuple[DispatchOutcome, DispatchOutcome] so the 2-result contract is explicit at the type level. - Widen the monitor_dispatched_workflows guard from RuntimeError to Exception so httpx network failures abort cleanly with the standard "Failed to monitor workflows" message. - Guard list_tags against non-JSON 2xx responses so registry_errors translates them into a clean abort instead of a raw ValueError. * Make test-agent monitor resilient and quieter - Tolerate transient httpx errors during polling: keep the prior monitor state instead of aborting the entire monitor on one bad request. - Emit a single final panel in both interactive and non-interactive paths so CI logs no longer get flooded with per-poll panels. - Move DispatchedWorkflow under TYPE_CHECKING in monitoring.py so importing monitoring does not eagerly load dispatch and its async machinery. - Surface the dispatched-run html_urls in the monitor-failed abort message so the user can resume monitoring manually. - Tighten test_dispatch fixtures from list[...] to tuple[...] to match the production signature. - Annotate the FakeAsyncGitHubClient.list_workflow_run_jobs stub with AsyncIterator[GitHubResponse[Any]] for consistency. * Format test_github_async.py with ruff --------- Co-authored-by: Alexey Pilyugin <alexey.pilyugin@datadoghq.com>
1 parent f80cdd0 commit 8bdc2df

24 files changed

Lines changed: 2233 additions & 28 deletions

ddev/changelog.d/23722.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `ddev release test-agent` command that dispatches the Linux and Windows Agent test workflows against a release branch or tag.

ddev/src/ddev/cli/ci/tests/task_test_runner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ async def process_message(self, message: TestBatch) -> None:
6363
log_extra: dict[str, Any] = {"batch_id": message.id}
6464

6565
dispatch = await self._client.create_workflow_dispatch(
66-
self._options.owner, self._options.repo, self._options.workflow_id, ref=self._options.ref, inputs=inputs
66+
self._options.owner,
67+
self._options.repo,
68+
self._options.workflow_id,
69+
ref=self._options.ref,
70+
inputs=inputs,
71+
return_run_details=True,
6772
)
6873
run_id = dispatch.data.workflow_run_id
6974
log_extra["run_id"] = run_id

ddev/src/ddev/cli/release/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ddev.cli.release.port_commit import port_commit
1515
from ddev.cli.release.show import show
1616
from ddev.cli.release.stats import stats
17+
from ddev.cli.release.test_agent import test_agent
1718

1819

1920
@click.group(short_help='Manage the release of integrations')
@@ -33,4 +34,5 @@ def release():
3334
release.add_command(show)
3435
release.add_command(stats)
3536
release.add_command(tag)
37+
release.add_command(test_agent)
3638
release.add_command(upload)

ddev/src/ddev/cli/release/port_commit_workflow.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ def resolve_port_plan(
695695
dry_run=dry_run,
696696
)
697697

698-
app.output(_format_plan_summary(plan), stderr=True)
698+
app.output(_format_plan_summary(app, plan), stderr=True)
699699

700700
if not dry_run and not click.confirm('Continue?'):
701701
app.abort('Did not get confirmation, aborting.')
@@ -705,7 +705,6 @@ def resolve_port_plan(
705705

706706
def display_completion_summary(app: Application, plan: PortPlan, *, pr_url: str | None) -> None:
707707
"""Print a panel summarising the port outcome."""
708-
text = Text()
709708
rows: list[tuple[str, str]] = [
710709
('Commit', f'{plan.full_sha[:10]} - {plan.clean_subject}'),
711710
('Target', plan.target_branch),
@@ -714,17 +713,13 @@ def display_completion_summary(app: Application, plan: PortPlan, *, pr_url: str
714713
if pr_url is not None:
715714
rows.append(('Pull request', pr_url))
716715

717-
label_width = max(len(label) for label, _ in rows)
718-
for i, (label, value) in enumerate(rows):
719-
if i:
720-
text.append('\n')
721-
text.append(f'{label}:'.ljust(label_width + 2), style='bold')
722-
text.append(value)
723-
724-
app.output(Panel(text, title='Backport completed', title_align='left', border_style='cyan'), stderr=True)
716+
app.output(
717+
Panel(app.labeled_lines(rows), title='Backport completed', title_align='left', border_style='cyan'),
718+
stderr=True,
719+
)
725720

726721

727-
def _format_plan_summary(plan: PortPlan) -> Text:
722+
def _format_plan_summary(app: Application, plan: PortPlan) -> Text:
728723
text = Text()
729724
text.append('Configuration:', style='bold')
730725

@@ -739,10 +734,8 @@ def _format_plan_summary(plan: PortPlan) -> Text:
739734
('Verify commit', str(plan.verify)),
740735
('Dry run', str(plan.dry_run)),
741736
]
742-
for label, value in rows:
743-
text.append('\n ')
744-
text.append(f'{label}:', style='bold')
745-
text.append(f' {value}')
737+
text.append('\n')
738+
text.append_text(app.labeled_lines(rows, indent=' ', align=False))
746739
return text
747740

748741

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""`ddev release test-agent` — dispatch the Linux + Windows Agent test workflows.
5+
6+
The orchestration body lives here so the file reads top-to-bottom as the command's story.
7+
Each step delegates to a sibling module (`validation`, `images`, `dispatch`), and every
8+
helper module is imported lazily inside the function body so `ddev --help` only pays for
9+
`click` from this package.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import TYPE_CHECKING
15+
16+
import click
17+
18+
if TYPE_CHECKING:
19+
from collections.abc import Sequence
20+
21+
from ddev.cli.application import Application
22+
from ddev.cli.release.test_agent.dispatch import DispatchedWorkflow
23+
24+
25+
@click.command('test-agent', short_help='Dispatch the Agent test workflows against a branch or tag')
26+
@click.option('--branch', help='Release branch to test, e.g. `7.80.x`.')
27+
@click.option('--tag', help='Agent release tag to test, e.g. `7.80.0-rc.1` or `7.80.0`.')
28+
@click.option('--dry-run', is_flag=True, help='Resolve images and print the plan without dispatching.')
29+
@click.option('--monitor', is_flag=True, help='Monitor dispatched workflow jobs until both workflows finish.')
30+
@click.option(
31+
'--poll-interval',
32+
type=click.FloatRange(min=1.0),
33+
default=10.0,
34+
show_default=True,
35+
help='Seconds between workflow monitor polls.',
36+
)
37+
@click.option('--yes', '-y', is_flag=True, help='Skip the interactive confirmation prompt.')
38+
@click.pass_obj
39+
def test_agent(
40+
app: Application,
41+
branch: str | None,
42+
tag: str | None,
43+
dry_run: bool,
44+
monitor: bool,
45+
poll_interval: float,
46+
yes: bool,
47+
) -> None:
48+
"""Trigger `test-agent.yml` and `test-agent-windows.yml` against the resolved Agent image.
49+
50+
Exactly one of `--branch` or `--tag` must be provided. When `--branch` is given, the latest
51+
`MAJ.MIN.PATCH-rc.N` published to `registry.datadoghq.com/agent` is used as the Agent image.
52+
When `--tag` is given, that exact tag is used. Linux and Windows (servercore) variants are
53+
both validated against the registry before either workflow is dispatched.
54+
"""
55+
import logging
56+
57+
from ddev.cli.release.test_agent.dispatch import dispatch_both
58+
from ddev.cli.release.test_agent.images import build_image_refs, resolve_version, validate_images_exist
59+
from ddev.cli.release.test_agent.validation import (
60+
Branch,
61+
fetch_target,
62+
validate_input,
63+
verify_workflows_present_on_ref,
64+
)
65+
66+
logging.getLogger('httpx').setLevel(logging.WARNING)
67+
68+
target = validate_input(app, branch, tag)
69+
70+
if not app.config.github.token:
71+
app.abort('GitHub token required. Set `github.token` via `ddev config set github.token <token>`.')
72+
73+
fetch_target(app, target)
74+
verify_workflows_present_on_ref(app, target)
75+
app.display_info('')
76+
77+
version = resolve_version(app, target)
78+
app.display_info('')
79+
80+
validate_images_exist(app, version)
81+
linux_image, windows_image = build_image_refs(version)
82+
app.display_info('')
83+
84+
# GitHub's workflow_dispatch API expects every value in `inputs` to be a string, even for
85+
# `type: boolean` workflow inputs — booleans are parsed from the lowercase string form.
86+
# `test-py2='false'` is sent to both dispatches by design: this command is forward-looking
87+
# and tests Python 3 only, even on Windows (where `test-agent-windows.yml` defaults
88+
# `test-py2` to `true` for legacy reasons).
89+
inputs: dict[str, str] = {
90+
'test-py3': 'true',
91+
'test-py2': 'false',
92+
'agent-image': linux_image,
93+
'agent-image-windows': windows_image,
94+
}
95+
is_branch = isinstance(target, Branch)
96+
_print_plan(app, ref=target.name, version=version, is_branch=is_branch, inputs=inputs)
97+
app.display_info('')
98+
99+
if dry_run:
100+
app.display_info('Dry run — no workflows dispatched.')
101+
return
102+
103+
if not yes and not click.confirm('Dispatch both workflows?', default=False):
104+
app.abort('Aborted by user.')
105+
106+
try:
107+
workflows = dispatch_both(app.config.github.token, ref=target.name, inputs=inputs)
108+
except RuntimeError as e:
109+
app.abort(str(e))
110+
else:
111+
if not monitor:
112+
app.display_info('')
113+
_print_result(app, workflows=workflows)
114+
115+
if monitor:
116+
from ddev.cli.release.test_agent.monitoring import monitor_dispatched_workflows
117+
118+
try:
119+
monitor_dispatched_workflows(
120+
app,
121+
app.config.github.token,
122+
ref=target.name,
123+
workflows=workflows,
124+
poll_interval=poll_interval,
125+
)
126+
except Exception as e:
127+
urls = ', '.join(w.html_url for w in workflows)
128+
app.abort(f'Failed to monitor workflows: {e}. Runs are still in flight: {urls}')
129+
130+
131+
def _print_plan(
132+
app: Application,
133+
*,
134+
ref: str,
135+
version: str,
136+
is_branch: bool,
137+
inputs: dict[str, str],
138+
) -> None:
139+
"""Render the resolved dispatch plan via the stderr-bound `display_info` channel.
140+
141+
All progress lines (`display_waiting`/`display_success`) default to stderr; keeping the
142+
plan on the same channel means piping the command into a file leaves stdout clean and
143+
keeps the whole pre-dispatch narrative coherent on stderr.
144+
"""
145+
from rich.panel import Panel
146+
147+
from ddev.cli.release.test_agent.validation import WORKFLOW_LINUX, WORKFLOW_WINDOWS
148+
149+
rows: list[tuple[str, str]] = [
150+
('Workflows', f'{WORKFLOW_LINUX}, {WORKFLOW_WINDOWS}'),
151+
('Ref', ref),
152+
]
153+
if is_branch:
154+
rows.append(('Resolved RC', version))
155+
rows.extend(inputs.items())
156+
157+
app.output(
158+
Panel(app.labeled_lines(rows), title='Dispatch plan', title_align='left', border_style='cyan'), stderr=True
159+
)
160+
161+
162+
def _print_result(app: Application, *, workflows: Sequence[DispatchedWorkflow]) -> None:
163+
"""Render the two run URLs in a rich Panel, matching the look of `ddev release port-commit`."""
164+
from rich.panel import Panel
165+
166+
rows = [(workflow.label, workflow.html_url) for workflow in workflows]
167+
app.output(
168+
Panel(app.labeled_lines(rows), title='Workflows dispatched', title_align='left', border_style='cyan'),
169+
stderr=True,
170+
)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Parallel `workflow_dispatch` orchestration for `ddev release test-agent`."""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
from dataclasses import dataclass
10+
from typing import TYPE_CHECKING
11+
12+
from ddev.cli.release.test_agent.validation import REPO_NAME, REPO_OWNER, WORKFLOW_LINUX, WORKFLOW_WINDOWS
13+
14+
if TYPE_CHECKING:
15+
from ddev.utils.github_async import GitHubResponse
16+
from ddev.utils.github_async.models import WorkflowDispatchResult
17+
18+
type DispatchOutcome = GitHubResponse[WorkflowDispatchResult] | BaseException
19+
20+
21+
@dataclass(frozen=True)
22+
class DispatchedWorkflow:
23+
"""A workflow run created by `ddev release test-agent`."""
24+
25+
label: str
26+
workflow_id: str
27+
run_id: int
28+
html_url: str
29+
30+
31+
def dispatch_both(token: str, *, ref: str, inputs: dict[str, str]) -> tuple[DispatchedWorkflow, DispatchedWorkflow]:
32+
"""Dispatch both workflows in parallel via the async GitHub client."""
33+
from ddev.utils.github_async import async_github_client
34+
35+
async def run_dispatches() -> tuple[DispatchOutcome, DispatchOutcome]:
36+
async with async_github_client(token=token) as client:
37+
return await asyncio.gather(
38+
client.create_workflow_dispatch(
39+
owner=REPO_OWNER,
40+
repo=REPO_NAME,
41+
workflow_id=WORKFLOW_LINUX,
42+
ref=ref,
43+
inputs=inputs,
44+
return_run_details=True,
45+
),
46+
client.create_workflow_dispatch(
47+
owner=REPO_OWNER,
48+
repo=REPO_NAME,
49+
workflow_id=WORKFLOW_WINDOWS,
50+
ref=ref,
51+
inputs=inputs,
52+
return_run_details=True,
53+
),
54+
return_exceptions=True,
55+
)
56+
57+
return extract_dispatched_workflows(asyncio.run(run_dispatches()))
58+
59+
60+
def extract_dispatched_workflows(
61+
results: tuple[DispatchOutcome, DispatchOutcome],
62+
) -> tuple[DispatchedWorkflow, DispatchedWorkflow]:
63+
"""Pull workflow runs out of two gather results, raising on any exception with a partial-success hint.
64+
65+
`asyncio.gather(return_exceptions=True)` captures `CancelledError`/`KeyboardInterrupt`
66+
(`BaseException` subclasses, not `Exception`) into its result list. Re-raise those first
67+
so flow-control exceptions propagate cleanly instead of being wrapped in `RuntimeError`.
68+
"""
69+
linux_result, windows_result = results
70+
71+
for result in (linux_result, windows_result):
72+
if isinstance(result, BaseException) and not isinstance(result, Exception):
73+
raise result
74+
75+
if isinstance(linux_result, BaseException):
76+
if isinstance(windows_result, BaseException):
77+
raise RuntimeError(
78+
f'Both dispatches failed. Linux: {linux_result!r}. Windows: {windows_result!r}.'
79+
) from linux_result
80+
sibling = windows_result.data.html_url
81+
raise RuntimeError(
82+
f'Linux dispatch failed: {linux_result}. The other workflow was dispatched at {sibling}.'
83+
) from linux_result
84+
85+
if isinstance(windows_result, BaseException):
86+
sibling = linux_result.data.html_url
87+
raise RuntimeError(
88+
f'Windows dispatch failed: {windows_result}. The other workflow was dispatched at {sibling}.'
89+
) from windows_result
90+
91+
return (
92+
DispatchedWorkflow(
93+
label='Linux',
94+
workflow_id=WORKFLOW_LINUX,
95+
run_id=linux_result.data.workflow_run_id,
96+
html_url=linux_result.data.html_url,
97+
),
98+
DispatchedWorkflow(
99+
label='Windows',
100+
workflow_id=WORKFLOW_WINDOWS,
101+
run_id=windows_result.data.workflow_run_id,
102+
html_url=windows_result.data.html_url,
103+
),
104+
)

0 commit comments

Comments
 (0)