Skip to content

Commit 12c11b5

Browse files
authored
Show exact run URL and add lifecycle comment to wheel promotion (DataDog#23828)
* Show exact run URL and add lifecycle comment to wheel promotion - Extend dispatch_workflow with return_run_details so callers can get back the new run's html_url instead of a generic recent-runs link. - ddev dep promote now prints the exact workflow run URL and suppresses noisy httpx request logs around the API calls. - Replace the single success comment in dependency-wheel-promotion.yaml with a lifecycle comment that updates on start, success, and failure, scoped per (PR, head SHA) via a hidden marker so re-dispatches edit the same comment. * Harden lifecycle comment chaining and github-script inputs - Started-comment step now references find_comment.outputs.comment-id (the previous version pointed at its own step output, so re-dispatches for the same SHA would not have updated the existing comment). - Pass inputs.head_sha into actions/github-script via env: HEAD_SHA and read process.env.HEAD_SHA in the script body, so a hostile workflow_dispatch input cannot break out of the JS string literal and execute arbitrary code. * Type-narrow dispatch_workflow and bail out cleanly on missing run details - Add Literal[True]/Literal[False] overloads to GitHubManager.dispatch_workflow so callers asking for run details get a non-nullable dict back at the type level. - Replace the bare assert in ddev dep promote with an explicit app.abort, run the validity check before printing the success message, and keep the success output inside the httpx-suppression scope. - Add ddev/changelog.d/23828.added so the PR-changelog check passes for the ddev source changes. - Lift the github credentials setup into ddev/tests/cli/dep/conftest.py as an autouse fixture, hoist the test-side logging import, and add coverage for the no-run-details abort path and the failure-path httpx level restoration. - Match the cleaner api_post.call_args.kwargs form already used in the companion test in tests/utils/test_github.py. * Trim runtime imports and share httpx-debug fixture across promote tests - Move Any and Literal under TYPE_CHECKING in github.py; they are only used inside annotations that PEP 563 keeps as strings, so they have no runtime cost. The overload decorator stays at module scope because it runs at class definition time. - Add an httpx_at_debug fixture in tests/cli/dep/conftest.py and use it from both httpx-suppression tests so the get-logger/set-DEBUG/restore boilerplate lives in one place. * Type-annotate the new ddev/tests/cli/dep fixtures
1 parent 9a6d486 commit 12c11b5

7 files changed

Lines changed: 268 additions & 31 deletions

File tree

.github/workflows/dependency-wheel-promotion.yaml

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ jobs:
2727
- name: Checkout trusted code
2828
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2929

30+
- name: Find existing lifecycle comment
31+
id: find_comment
32+
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
33+
with:
34+
issue-number: ${{ inputs.pr_number }}
35+
body-includes: "<!-- dependency-wheel-promotion pr=${{ inputs.pr_number }} sha=${{ inputs.head_sha }} -->"
36+
37+
- name: Post lifecycle comment (started)
38+
id: started_comment
39+
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
40+
with:
41+
issue-number: ${{ inputs.pr_number }}
42+
comment-id: ${{ steps.find_comment.outputs.comment-id }}
43+
edit-mode: replace
44+
body: |
45+
<!-- dependency-wheel-promotion pr=${{ inputs.pr_number }} sha=${{ inputs.head_sha }} -->
46+
Wheel promotion started for commit `${{ inputs.head_sha }}` by @${{ github.actor }}.
47+
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
48+
3049
- name: Checkout PR lockfiles only
3150
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3251
with:
@@ -62,41 +81,57 @@ jobs:
6281

6382
- name: Set dependency-wheel-promotion status to success
6483
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
84+
env:
85+
HEAD_SHA: ${{ inputs.head_sha }}
6586
with:
6687
script: |
6788
await github.rest.repos.createCommitStatus({
6889
owner: context.repo.owner,
6990
repo: context.repo.repo,
70-
sha: '${{ inputs.head_sha }}',
91+
sha: process.env.HEAD_SHA,
7192
state: 'success',
7293
context: 'dependency-wheel-promotion',
7394
description: 'Wheels promoted to stable storage.',
7495
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
7596
});
7697
77-
- name: Post success comment
78-
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
98+
- name: Update lifecycle comment (success)
99+
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
79100
with:
80-
script: |
81-
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
82-
await github.rest.issues.createComment({
83-
owner: context.repo.owner,
84-
repo: context.repo.repo,
85-
issue_number: ${{ inputs.pr_number }},
86-
body: `Wheels promoted to stable storage for commit ${{ inputs.head_sha }} by @${context.actor}. [Workflow run](${runUrl}).`,
87-
});
101+
issue-number: ${{ inputs.pr_number }}
102+
comment-id: ${{ steps.started_comment.outputs.comment-id }}
103+
edit-mode: replace
104+
body: |
105+
<!-- dependency-wheel-promotion pr=${{ inputs.pr_number }} sha=${{ inputs.head_sha }} -->
106+
Wheels promoted to stable storage for commit `${{ inputs.head_sha }}` by @${{ github.actor }}.
107+
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
88108
89109
- name: Set dependency-wheel-promotion status to error
90110
if: failure()
91111
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
112+
env:
113+
HEAD_SHA: ${{ inputs.head_sha }}
92114
with:
93115
script: |
94116
await github.rest.repos.createCommitStatus({
95117
owner: context.repo.owner,
96118
repo: context.repo.repo,
97-
sha: '${{ inputs.head_sha }}',
119+
sha: process.env.HEAD_SHA,
98120
state: 'error',
99121
context: 'dependency-wheel-promotion',
100122
description: 'Wheel promotion failed. Check the Actions tab for details.',
101123
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
102124
});
125+
126+
- name: Update lifecycle comment (failure)
127+
if: failure()
128+
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
129+
with:
130+
issue-number: ${{ inputs.pr_number }}
131+
comment-id: ${{ steps.started_comment.outputs.comment-id }}
132+
edit-mode: replace
133+
body: |
134+
<!-- dependency-wheel-promotion pr=${{ inputs.pr_number }} sha=${{ inputs.head_sha }} -->
135+
Wheel promotion failed for commit `${{ inputs.head_sha }}` by @${{ github.actor }}.
136+
Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
137+
Check the workflow logs before retrying.

ddev/changelog.d/23828.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Print the exact workflow run URL when dispatching `ddev dep promote`, via a new `return_run_details` option on `GitHubManager.dispatch_workflow`.

ddev/src/ddev/cli/dep/promote.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44
from __future__ import annotations
55

6+
import logging
67
import re
78
from typing import TYPE_CHECKING
89

@@ -39,20 +40,26 @@ def promote(app: Application, pr_url: str):
3940

4041
pr_number = int(match.group(1))
4142

42-
with app.status(f'Fetching PR #{pr_number} head...'):
43-
head_sha, head_ref = app.github.get_pr_head(pr_number)
43+
httpx_logger = logging.getLogger('httpx')
44+
previous_level = httpx_logger.level
45+
httpx_logger.setLevel(logging.WARNING)
46+
try:
47+
with app.status(f'Fetching PR #{pr_number} head...'):
48+
head_sha, head_ref = app.github.get_pr_head(pr_number)
4449

45-
app.display_info(f'PR #{pr_number}branch: {head_ref}, SHA: {head_sha}')
50+
app.display_info(f'PR #{pr_number}: branch {head_ref}, SHA {head_sha}')
4651

47-
with app.status('Dispatching promote workflow...'):
48-
app.github.dispatch_workflow(
49-
workflow_id=PROMOTE_WORKFLOW,
50-
ref=PROMOTE_WORKFLOW_REF,
51-
inputs={'pr_number': str(pr_number), 'head_sha': head_sha},
52-
)
52+
with app.status('Dispatching promote workflow...'):
53+
run_details = app.github.dispatch_workflow(
54+
workflow_id=PROMOTE_WORKFLOW,
55+
ref=PROMOTE_WORKFLOW_REF,
56+
inputs={'pr_number': str(pr_number), 'head_sha': head_sha},
57+
return_run_details=True,
58+
)
5359

54-
runs_url = (
55-
f'https://github.com/{app.github.repo_id}/actions/workflows/{PROMOTE_WORKFLOW}?query=event%3Aworkflow_dispatch'
56-
)
57-
app.display_success(f'Promote workflow dispatched for PR #{pr_number}.')
58-
app.display_info(f'Recent runs: {runs_url}')
60+
if not run_details:
61+
app.abort('Workflow dispatched but no run details were returned.')
62+
app.display_success(f'Promote workflow dispatched for PR #{pr_number}.')
63+
app.display_info(f'Workflow run: {run_details["html_url"]}')
64+
finally:
65+
httpx_logger.setLevel(previous_level)

ddev/src/ddev/utils/github.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import json
77
from functools import cached_property
88
from time import time
9-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, overload
1010

1111
if TYPE_CHECKING:
12+
from typing import Any, Literal
13+
1214
from httpx import Client
1315

1416
from ddev.cli.terminal import BorrowedStatus
@@ -217,12 +219,48 @@ def get_pull_request_labels(self, pr_number: int) -> list[str] | None:
217219
return None
218220
return [label['name'] for label in response.json().get('labels', [])]
219221

220-
def dispatch_workflow(self, workflow_id: str, ref: str, inputs: dict[str, Any]) -> None:
221-
"""Trigger a workflow_dispatch event."""
222-
self.__api_post(
222+
@overload
223+
def dispatch_workflow(
224+
self,
225+
workflow_id: str,
226+
ref: str,
227+
inputs: dict[str, Any],
228+
return_run_details: Literal[False] = False,
229+
) -> None: ...
230+
231+
@overload
232+
def dispatch_workflow(
233+
self,
234+
workflow_id: str,
235+
ref: str,
236+
inputs: dict[str, Any],
237+
return_run_details: Literal[True],
238+
) -> dict[str, Any]: ...
239+
240+
def dispatch_workflow(
241+
self,
242+
workflow_id: str,
243+
ref: str,
244+
inputs: dict[str, Any],
245+
return_run_details: bool = False,
246+
) -> dict[str, Any] | None:
247+
"""Trigger a workflow_dispatch event.
248+
249+
When ``return_run_details`` is true, request the new run's details from
250+
the API and return the parsed JSON response (``workflow_run_id``,
251+
``run_url``, ``html_url``). The default keeps the prior fire-and-forget
252+
behavior and returns ``None``.
253+
"""
254+
payload: dict[str, Any] = {'ref': ref, 'inputs': inputs}
255+
if return_run_details:
256+
payload['return_run_details'] = True
257+
response = self.__api_post(
223258
self.WORKFLOW_DISPATCH_API.format(repo_id=self.repo_id, workflow_id=workflow_id),
224-
content=json.dumps({'ref': ref, 'inputs': inputs}),
259+
content=json.dumps(payload),
225260
)
261+
if not return_run_details:
262+
return None
263+
return response.json()
226264

227265
def get_pull_request_comments(self, pr_number: int) -> list[dict]:
228266
response = self.__api_get(

ddev/tests/cli/dep/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
import logging
7+
from collections.abc import Generator
8+
from typing import TYPE_CHECKING
9+
10+
import pytest
11+
12+
if TYPE_CHECKING:
13+
from ddev.config.file import ConfigFileWithOverrides
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def configure_github_credentials(config_file: ConfigFileWithOverrides) -> None:
18+
"""Provide github credentials so commands that touch app.github do not abort."""
19+
config_file.model.github = {'user': 'test-user', 'token': 'test-token'}
20+
config_file.save()
21+
22+
23+
@pytest.fixture
24+
def httpx_at_debug() -> Generator[logging.Logger, None, None]:
25+
"""Force the httpx logger to DEBUG and restore its previous level on teardown."""
26+
logger = logging.getLogger('httpx')
27+
previous_level = logger.level
28+
logger.setLevel(logging.DEBUG)
29+
try:
30+
yield logger
31+
finally:
32+
logger.setLevel(previous_level)

ddev/tests/cli/dep/test_promote.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import logging
5+
6+
import pytest
7+
8+
RUN_DETAILS = {
9+
'workflow_run_id': 999,
10+
'run_url': 'https://api.github.com/repos/DataDog/integrations-core/actions/runs/999',
11+
'html_url': 'https://github.com/DataDog/integrations-core/actions/runs/999',
12+
}
13+
14+
15+
def test_promote_dispatches_workflow_and_prints_run_url(ddev, mocker):
16+
mocker.patch('ddev.utils.github.GitHubManager.get_pr_head', return_value=('deadbeef', 'feature-branch'))
17+
dispatch = mocker.patch('ddev.utils.github.GitHubManager.dispatch_workflow', return_value=RUN_DETAILS)
18+
19+
result = ddev('dep', 'promote', 'https://github.com/DataDog/integrations-core/pull/12345')
20+
21+
assert result.exit_code == 0, result.output
22+
dispatch.assert_called_once_with(
23+
workflow_id='dependency-wheel-promotion.yaml',
24+
ref='master',
25+
inputs={'pr_number': '12345', 'head_sha': 'deadbeef'},
26+
return_run_details=True,
27+
)
28+
assert 'PR #12345' in result.output
29+
assert 'feature-branch' in result.output
30+
assert 'deadbeef' in result.output
31+
assert RUN_DETAILS['html_url'] in result.output
32+
assert 'Recent runs' not in result.output
33+
assert 'query=event%3Aworkflow_dispatch' not in result.output
34+
35+
36+
def test_promote_invalid_pr_url_aborts(ddev):
37+
result = ddev('dep', 'promote', 'https://example.invalid/not-a-pr')
38+
39+
assert result.exit_code != 0
40+
assert 'Could not extract a PR number' in result.output
41+
42+
43+
def test_promote_aborts_when_no_run_details_returned(ddev, mocker):
44+
mocker.patch('ddev.utils.github.GitHubManager.get_pr_head', return_value=('deadbeef', 'feature-branch'))
45+
mocker.patch('ddev.utils.github.GitHubManager.dispatch_workflow', return_value=None)
46+
47+
result = ddev('dep', 'promote', 'https://github.com/DataDog/integrations-core/pull/12345')
48+
49+
assert result.exit_code != 0
50+
assert 'no run details were returned' in result.output
51+
assert 'Promote workflow dispatched' not in result.output
52+
53+
54+
def test_promote_suppresses_httpx_logs_and_restores_level(ddev, mocker, httpx_at_debug):
55+
captured_levels = []
56+
57+
def capture_level(*_args, **_kwargs):
58+
captured_levels.append(httpx_at_debug.level)
59+
return ('deadbeef', 'feature-branch')
60+
61+
mocker.patch('ddev.utils.github.GitHubManager.get_pr_head', side_effect=capture_level)
62+
mocker.patch('ddev.utils.github.GitHubManager.dispatch_workflow', return_value=RUN_DETAILS)
63+
64+
result = ddev('dep', 'promote', 'https://github.com/DataDog/integrations-core/pull/12345')
65+
66+
assert result.exit_code == 0, result.output
67+
assert captured_levels == [logging.WARNING]
68+
assert httpx_at_debug.level == logging.DEBUG
69+
70+
71+
def test_promote_restores_httpx_log_level_on_failure(ddev, mocker, httpx_at_debug):
72+
"""Ensure the finally branch restores the previous httpx logger level even when an API call raises."""
73+
mocker.patch('ddev.utils.github.GitHubManager.get_pr_head', side_effect=RuntimeError('boom'))
74+
mocker.patch('ddev.utils.github.GitHubManager.dispatch_workflow')
75+
76+
with pytest.raises(RuntimeError, match='boom'):
77+
ddev('dep', 'promote', 'https://github.com/DataDog/integrations-core/pull/12345')
78+
79+
assert httpx_at_debug.level == logging.DEBUG

ddev/tests/utils/test_github.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# (C) Datadog, Inc. 2023-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import json
5+
46
import pytest
57

68
from ddev.utils.github import PullRequest
@@ -83,3 +85,46 @@ def test_create_label(self, network_replay, github_manager):
8385

8486
assert label.json()['name'] == 'my_custom_label'
8587
assert label.json()['color'] == 'ff0000'
88+
89+
90+
def test_dispatch_workflow_default_returns_none(github_manager, mocker):
91+
"""Default dispatch_workflow keeps the prior fire-and-forget behavior."""
92+
response = mocker.MagicMock()
93+
api_post = mocker.patch('ddev.utils.github.GitHubManager._GitHubManager__api_post', return_value=response)
94+
95+
result = github_manager.dispatch_workflow(
96+
workflow_id='example.yaml',
97+
ref='master',
98+
inputs={'pr_number': '123', 'head_sha': 'deadbeef'},
99+
)
100+
101+
assert result is None
102+
api_post.assert_called_once()
103+
payload = json.loads(api_post.call_args.kwargs['content'])
104+
assert payload == {'ref': 'master', 'inputs': {'pr_number': '123', 'head_sha': 'deadbeef'}}
105+
assert 'return_run_details' not in payload
106+
107+
108+
def test_dispatch_workflow_return_run_details_sends_flag_and_returns_json(github_manager, mocker):
109+
"""When return_run_details is true, the payload includes the flag and the parsed JSON is returned."""
110+
run_details = {
111+
'workflow_run_id': 42,
112+
'run_url': 'https://api.github.com/repos/o/r/actions/runs/42',
113+
'html_url': 'https://github.com/o/r/actions/runs/42',
114+
}
115+
response = mocker.MagicMock()
116+
response.json.return_value = run_details
117+
api_post = mocker.patch('ddev.utils.github.GitHubManager._GitHubManager__api_post', return_value=response)
118+
119+
result = github_manager.dispatch_workflow(
120+
workflow_id='example.yaml',
121+
ref='master',
122+
inputs={'pr_number': '123', 'head_sha': 'deadbeef'},
123+
return_run_details=True,
124+
)
125+
126+
assert result == run_details
127+
payload = json.loads(api_post.call_args.kwargs['content'])
128+
assert payload['return_run_details'] is True
129+
assert payload['ref'] == 'master'
130+
assert payload['inputs'] == {'pr_number': '123', 'head_sha': 'deadbeef'}

0 commit comments

Comments
 (0)