Skip to content

Commit 0188175

Browse files
iliakurclaudedd-agent-integrations-bot[bot]
authored
Resolve and build dependency wheels in one PR with the dependency bump that triggers the build (DataDog#23063)
* Pass PACKAGE_BASE_URL to triggered agent builds When integrations-core triggers agent CI builds, pass PACKAGE_BASE_URL pointing to dev storage. This prepares for lockfiles switching to ${PACKAGE_BASE_URL}/... format so PR-triggered builds use dev wheels. No-op today since current lockfiles use hardcoded URLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update ddev size tools to handle both lockfile URL formats Support both the legacy hardcoded URL format and the new \${PACKAGE_BASE_URL}/... template format in lockfile entries. Resolves \${PACKAGE_BASE_URL} to the stable base URL before downloading wheels for size calculations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Upload wheels to dev/ prefix and use \${PACKAGE_BASE_URL} in lockfiles Wheels are now uploaded to dev/{artifact_type}/{project_name}/ paths in GCS instead of the unprefixed paths. Lockfile entries are templated with \${PACKAGE_BASE_URL} so pip resolves the URL at install time using either the dev or stable base URL depending on the environment. Also fix brittle index extraction in generate_artifact_listings and list_wheels_with_prefix to use split('/')[-1] and split('/')[-2] instead of hardcoded indices. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update promote.py to parse \${PACKAGE_BASE_URL} lockfile format Lockfiles now use \${PACKAGE_BASE_URL}/... template entries instead of hardcoded URLs. Update url_to_blob_path to extract the relative path from \${PACKAGE_BASE_URL}/... entries, then prepend dev/ when looking up blobs in GCS and stable/ for the promotion destination. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Change publish job to run on PRs only and commit lockfiles to branch Instead of creating a separate PR with updated lockfiles, the publish job now commits them directly to the PR branch. This collapses the two-PR dependency update workflow into a single PR. - Trigger: pull_request only (remove push and workflow_dispatch) - Permission: contents: write (needed for git push) - Token: GitHub App token checked out before checkout so push works - Replace peter-evans/create-pull-request with a git commit + push step Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add promote-gate and promote-wheels workflows promote-gate.yaml runs on every PR push to master/7.*.*. If dependency files (agent_requirements.in or .deps/resolved/) changed, it sets the promote-wheels commit status to pending, blocking merge. Otherwise it sets it to success (no promotion needed). promote-wheels.yaml is triggered via workflow_dispatch (by ddev promote). It checks out the PR branch at the given SHA, runs .builders/promote.py to copy wheels from dev/ to stable/ in GCS, then sets the promote-wheels commit status to success and posts a comment on the PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update upload tests and add promote tests for new lockfile format test_upload.py: update all blob path assertions to use dev/ prefix and all lockfile URL assertions to use \${PACKAGE_BASE_URL} format. Update generate_artifact_listings assertions to use dev/-prefixed paths. test_promote.py (new): test lockfile parsing, url_to_blob_path, collect_relative_paths, GCS copy with correct dev/stable paths, idempotency, and failure on missing source blobs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * add changelog * Update dependency resolution [skip ci] * Replace PACKAGE_BASE_URL with INTEGRATIONS_WHEELS_STORAGE PACKAGE_BASE_URL was a full URL env var, which is more than needed and potentially dangerous. Replace it with INTEGRATIONS_WHEELS_STORAGE whose value is only "dev" or "stable". Lockfile entries now use the form: https://agent-int-packages.datadoghq.com/\${INTEGRATIONS_WHEELS_STORAGE}/... The base domain is hardcoded; only the storage tier is variable. This limits what a compromised env var could redirect to. Update all affected files: upload.py, promote.py, size tools, tests, build_agent.yaml, and the promotion workflows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Rename promotion workflows to dependency-wheel-promotion{,-gate} Rename promote-gate.yaml -> dependency-wheel-promotion-gate.yaml and promote-wheels.yaml -> dependency-wheel-promotion.yaml so the two related workflows sort next to each other and their purpose is explicit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix promote-gate to use GitHub API instead of git diff The actions/checkout shallow clone does not include the base branch, so git diff --name-only against origin/<base> fails. Replace the git command with a GitHub API call (pulls.listFiles) which does not require a checkout at all. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Skip publish job for fork PRs in resolve-build-deps Fork PRs cannot access repo secrets (GCS credentials, GitHub App key) or the Workload Identity Provider used by google-github-actions/auth. Add !github.event.pull_request.head.repo.fork to the publish job condition so it only runs on PRs from branches within the repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add ddev dep promote command to trigger wheel promotion ddev dep promote <PR_URL> extracts the PR number from the URL, fetches the head SHA and branch from the GitHub API, then dispatches the dependency-wheel-promotion workflow via workflow_dispatch. This avoids both wasted runner minutes (no issue_comment polling) and new infrastructure (no webhook handler). The workflow only runs when explicitly dispatched. Also add get_pr_head() and dispatch_workflow() to GitHubManager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix lint * codex feedback * Update dependency resolution [skip ci] * Enable CI when resolving deps * Address feedback * Update dependency resolution [skip ci] * Temporarily use push trigger on promotion gate for testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update dependency resolution [skip ci] * Update dependency resolution [skip ci] * Fix dependency resolution commit message (again) * Update dependency resolution * Fix build-agent-auto glob to match .deps subdirectories The `.deps/*` glob only matches files directly in `.deps/`, not in subdirectories like `.deps/resolved/`. Use `**/*` to recurse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Revert "Temporarily use push trigger on promotion gate for testing" This reverts commit 28fb9d5. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: dd-agent-integrations-bot[bot] <dd-agent-integrations-bot[bot]@users.noreply.github.com>
1 parent 71bd116 commit 0188175

20 files changed

Lines changed: 1197 additions & 697 deletions

.builders/promote.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Promote dependency wheels from dev to stable storage.
2+
3+
Reads lockfiles from .deps/resolved/, identifies every wheel that lives
4+
under the ``dev/`` prefix in GCS, and copies it to the ``stable/`` prefix.
5+
Invoked via ``ddev promote <PR_URL>`` which dispatches the promote workflow.
6+
"""
7+
from __future__ import annotations
8+
9+
import re
10+
import sys
11+
from pathlib import Path, PurePosixPath
12+
13+
from google.cloud import storage
14+
15+
BUCKET_NAME = "deps-agent-int-datadoghq-com"
16+
REPO_DIR = Path(__file__).resolve().parent.parent
17+
LOCK_FILE_DIR = REPO_DIR / ".deps" / "resolved"
18+
19+
DEV_PREFIX = "dev/"
20+
STABLE_PREFIX = "stable/"
21+
22+
LOCKFILE_ENTRY = re.compile(
23+
r"^(?P<name>\S+)\s+@\s+(?P<url>\S+)$"
24+
)
25+
26+
27+
def parse_lockfile_urls(lockfile: Path) -> list[str]:
28+
"""Extract wheel URLs from a lockfile."""
29+
urls: list[str] = []
30+
for line in lockfile.read_text().splitlines():
31+
line = line.strip()
32+
if not line:
33+
continue
34+
m = LOCKFILE_ENTRY.match(line)
35+
if m:
36+
urls.append(m.group("url").split("#")[0])
37+
return urls
38+
39+
40+
STORAGE_BASE = "https://agent-int-packages.datadoghq.com/"
41+
STORAGE_TEMPLATE_PREFIX = f"{STORAGE_BASE}${{INTEGRATIONS_WHEELS_STORAGE}}/"
42+
43+
44+
def url_to_blob_path(url: str) -> str | None:
45+
"""Convert a wheel URL to its GCS blob path, or None if not a templated storage URL.
46+
47+
Handles the templated ``https://agent-int-packages.datadoghq.com/${INTEGRATIONS_WHEELS_STORAGE}/...``
48+
format used in lockfiles.
49+
"""
50+
if url.startswith(STORAGE_TEMPLATE_PREFIX):
51+
return url[len(STORAGE_TEMPLATE_PREFIX):]
52+
return None
53+
54+
55+
def collect_relative_paths() -> list[str]:
56+
"""Read all lockfiles and return relative wheel paths from ${INTEGRATIONS_WHEELS_STORAGE} entries."""
57+
if not LOCK_FILE_DIR.is_dir():
58+
print(f"No lockfile directory found at {LOCK_FILE_DIR}", file=sys.stderr)
59+
sys.exit(1)
60+
61+
lockfiles = list(LOCK_FILE_DIR.glob("*.txt"))
62+
if not lockfiles:
63+
print(f"No lockfiles found in {LOCK_FILE_DIR}", file=sys.stderr)
64+
sys.exit(1)
65+
66+
rel_paths: list[str] = []
67+
for lockfile in sorted(lockfiles):
68+
print(f"Reading {lockfile.name}")
69+
for url in parse_lockfile_urls(lockfile):
70+
rel_path = url_to_blob_path(url)
71+
if rel_path:
72+
rel_paths.append(rel_path)
73+
74+
return rel_paths
75+
76+
77+
def promote(rel_paths: list[str]) -> None:
78+
"""Copy blobs from dev/ to stable/ in GCS."""
79+
if not rel_paths:
80+
print("No templated wheels found in lockfiles — nothing to promote.")
81+
return
82+
83+
unique_paths = sorted(set(rel_paths))
84+
print(f"\nPromoting {len(unique_paths)} wheels from dev to stable...\n")
85+
86+
client = storage.Client()
87+
bucket = client.bucket(BUCKET_NAME)
88+
89+
failed: list[str] = []
90+
for rel_path in unique_paths:
91+
dev_path = DEV_PREFIX + rel_path
92+
stable_path = STABLE_PREFIX + rel_path
93+
name = PurePosixPath(rel_path).name
94+
source_blob = bucket.blob(dev_path)
95+
96+
if not source_blob.exists():
97+
print(f" MISSING {name}")
98+
failed.append(dev_path)
99+
continue
100+
101+
bucket.copy_blob(source_blob, bucket, stable_path)
102+
print(f" OK {name}")
103+
104+
print()
105+
if failed:
106+
print(
107+
f"ERROR: {len(failed)} wheel(s) not found in dev storage.\n"
108+
"The resolve-build-deps workflow may not have finished yet.\n"
109+
"Wait for it to complete, then run ddev promote again.",
110+
file=sys.stderr,
111+
)
112+
for p in failed:
113+
print(f" - {p}", file=sys.stderr)
114+
sys.exit(1)
115+
116+
print(f"Done. {len(unique_paths)} wheel(s) promoted to stable.")
117+
118+
119+
if __name__ == "__main__":
120+
rel_paths = collect_relative_paths()
121+
promote(rel_paths)

.builders/tests/test_promote.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from pathlib import Path
2+
from unittest import mock
3+
4+
import pytest
5+
import promote
6+
7+
BASE = "https://agent-int-packages.datadoghq.com/${INTEGRATIONS_WHEELS_STORAGE}"
8+
9+
10+
def write_lockfile(path: Path, entries: list[str]) -> None:
11+
path.write_text("\n".join(entries))
12+
13+
14+
def test_parse_lockfile_urls_templated(tmp_path):
15+
"""parse_lockfile_urls extracts URLs from ${INTEGRATIONS_WHEELS_STORAGE} lockfile entries."""
16+
lockfile = tmp_path / "linux-x86_64_3.13.txt"
17+
write_lockfile(lockfile, [
18+
f"aerospike @ {BASE}/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl#sha256=abc",
19+
f"requests @ {BASE}/external/requests/requests-2.32.0-py3-none-any.whl#sha256=def",
20+
"",
21+
])
22+
23+
urls = promote.parse_lockfile_urls(lockfile)
24+
25+
assert urls == [
26+
f"{BASE}/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl",
27+
f"{BASE}/external/requests/requests-2.32.0-py3-none-any.whl",
28+
]
29+
30+
31+
def test_url_to_blob_path_templated():
32+
"""url_to_blob_path extracts the relative path from a ${INTEGRATIONS_WHEELS_STORAGE} URL."""
33+
url = f"{BASE}/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl"
34+
assert promote.url_to_blob_path(url) == "built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl"
35+
36+
37+
def test_url_to_blob_path_returns_none_for_other_urls():
38+
"""url_to_blob_path returns None for non-templated URLs."""
39+
assert promote.url_to_blob_path("https://example.com/some.whl") is None
40+
assert promote.url_to_blob_path("https://agent-int-packages.datadoghq.com/built/foo/foo-1.0.whl") is None
41+
assert promote.url_to_blob_path("https://agent-int-packages.datadoghq.com/stable/built/foo/foo-1.0.whl") is None
42+
43+
44+
def test_collect_relative_paths(tmp_path):
45+
"""collect_relative_paths reads all lockfiles and returns relative paths."""
46+
lock_dir = tmp_path / ".deps" / "resolved"
47+
lock_dir.mkdir(parents=True)
48+
49+
write_lockfile(lock_dir / "linux-x86_64_3.13.txt", [
50+
f"aerospike @ {BASE}/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl#sha256=abc",
51+
])
52+
write_lockfile(lock_dir / "linux-aarch64_3.13.txt", [
53+
f"aerospike @ {BASE}/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_aarch64.whl#sha256=xyz",
54+
])
55+
56+
with mock.patch.object(promote, "LOCK_FILE_DIR", lock_dir):
57+
paths = promote.collect_relative_paths()
58+
59+
assert sorted(paths) == [
60+
"built/aerospike/aerospike-7.1.1-cp313-cp313-linux_aarch64.whl",
61+
"built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl",
62+
]
63+
64+
65+
def test_collect_relative_paths_preserves_duplicates(tmp_path):
66+
"""collect_relative_paths returns all paths even when shared across lockfiles."""
67+
lock_dir = tmp_path / ".deps" / "resolved"
68+
lock_dir.mkdir(parents=True)
69+
70+
shared_entry = f"requests @ {BASE}/external/requests/requests-2.32.0-py3-none-any.whl#sha256=def"
71+
write_lockfile(lock_dir / "linux-x86_64_3.13.txt", [shared_entry])
72+
write_lockfile(lock_dir / "linux-aarch64_3.13.txt", [shared_entry])
73+
74+
with mock.patch.object(promote, "LOCK_FILE_DIR", lock_dir):
75+
paths = promote.collect_relative_paths()
76+
77+
assert paths.count("external/requests/requests-2.32.0-py3-none-any.whl") == 2
78+
79+
80+
def test_promote_copies_blobs():
81+
"""promote copies each relative path from dev/ to stable/ in GCS."""
82+
rel_paths = [
83+
"built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl",
84+
"external/requests/requests-2.32.0-py3-none-any.whl",
85+
]
86+
87+
mock_client = mock.Mock()
88+
mock_bucket = mock.Mock()
89+
mock_client.bucket.return_value = mock_bucket
90+
91+
source_blob = mock.Mock()
92+
source_blob.exists.return_value = True
93+
mock_bucket.blob.return_value = source_blob
94+
95+
with mock.patch("promote.storage.Client", return_value=mock_client):
96+
promote.promote(rel_paths)
97+
98+
assert mock_bucket.blob.call_count == 2
99+
mock_bucket.blob.assert_any_call("dev/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl")
100+
mock_bucket.blob.assert_any_call("dev/external/requests/requests-2.32.0-py3-none-any.whl")
101+
102+
assert mock_bucket.copy_blob.call_count == 2
103+
mock_bucket.copy_blob.assert_any_call(
104+
source_blob, mock_bucket, "stable/built/aerospike/aerospike-7.1.1-cp313-cp313-linux_x86_64.whl"
105+
)
106+
mock_bucket.copy_blob.assert_any_call(
107+
source_blob, mock_bucket, "stable/external/requests/requests-2.32.0-py3-none-any.whl"
108+
)
109+
110+
111+
def test_promote_is_idempotent():
112+
"""promote succeeds even if the destination blob already exists (GCS copy is idempotent)."""
113+
rel_paths = ["built/foo/foo-1.0-cp313-cp313-linux_x86_64.whl"]
114+
115+
mock_client = mock.Mock()
116+
mock_bucket = mock.Mock()
117+
mock_client.bucket.return_value = mock_bucket
118+
119+
source_blob = mock.Mock()
120+
source_blob.exists.return_value = True
121+
mock_bucket.blob.return_value = source_blob
122+
123+
with mock.patch("promote.storage.Client", return_value=mock_client):
124+
promote.promote(rel_paths)
125+
promote.promote(rel_paths)
126+
127+
assert mock_bucket.copy_blob.call_count == 2
128+
129+
130+
def test_promote_fails_if_source_missing(capsys):
131+
"""promote exits with error if a source blob is not found in dev/."""
132+
rel_paths = ["built/missing/missing-1.0-cp313-cp313-linux_x86_64.whl"]
133+
134+
mock_client = mock.Mock()
135+
mock_bucket = mock.Mock()
136+
mock_client.bucket.return_value = mock_bucket
137+
138+
source_blob = mock.Mock()
139+
source_blob.exists.return_value = False
140+
mock_bucket.blob.return_value = source_blob
141+
142+
with mock.patch("promote.storage.Client", return_value=mock_client):
143+
with pytest.raises(SystemExit) as exc_info:
144+
promote.promote(rel_paths)
145+
146+
assert exc_info.value.code == 1
147+
captured = capsys.readouterr()
148+
assert "MISSING" in captured.out or "not found" in captured.err
149+
150+
151+
def test_promote_partial_failure(capsys):
152+
"""promote copies available blobs and exits with error for missing ones."""
153+
rel_paths = [
154+
"built/present/present-1.0-cp313-cp313-linux_x86_64.whl",
155+
"built/missing/missing-1.0-cp313-cp313-linux_x86_64.whl",
156+
]
157+
158+
mock_client = mock.Mock()
159+
mock_bucket = mock.Mock()
160+
mock_client.bucket.return_value = mock_bucket
161+
162+
present_blob = mock.Mock()
163+
present_blob.exists.return_value = True
164+
missing_blob = mock.Mock()
165+
missing_blob.exists.return_value = False
166+
mock_bucket.blob.side_effect = lambda path: missing_blob if "missing" in path else present_blob
167+
168+
with mock.patch("promote.storage.Client", return_value=mock_client):
169+
with pytest.raises(SystemExit) as exc_info:
170+
promote.promote(rel_paths)
171+
172+
assert exc_info.value.code == 1
173+
mock_bucket.copy_blob.assert_called_once_with(
174+
present_blob, mock_bucket, "stable/built/present/present-1.0-cp313-cp313-linux_x86_64.whl"
175+
)
176+
captured = capsys.readouterr()
177+
assert "MISSING" in captured.out
178+
179+
180+
def test_promote_nothing_to_promote():
181+
"""promote prints a message and returns early when given no paths."""
182+
with mock.patch("promote.storage.Client") as mock_client_cls:
183+
promote.promote([])
184+
185+
mock_client_cls.assert_not_called()

0 commit comments

Comments
 (0)