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
25 changes: 25 additions & 0 deletions .github/workflows/deps-tidy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,31 @@ jobs:
token: ${{ steps.setup.outputs.token }}
commit-message: "[renovate skip] Auto-repin Bazel Rust lockfile and regenerate Rust licenses"

bazel_native_tidy:
if: ${{ github.repository == 'DataDog/datadog-agent' && github.event.pull_request.user.login == 'renovate[bot]' && contains(github.event.pull_request.labels.*.name, 'dependencies-bazel-native') }}
permissions:
id-token: write # Required for dd-octo-sts OIDC token
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- uses: ./.github/actions/deps-tidy-setup
id: setup
- name: Install dda
uses: ./.github/actions/install-dda
with:
features: legacy-tasks
- name: Refresh http_archive sha256 for changed deps
env:
BRANCH: ${{ github.event.pull_request.base.ref }}
run: dda inv -- renovate.refresh-archive-hashes --base-ref=origin/${BRANCH}
- uses: ./.github/actions/deps-tidy-push
with:
token: ${{ steps.setup.outputs.token }}
commit-message: "[renovate skip] Auto-refresh http_archive sha256"

bazel_tidy:
if: ${{ github.repository == 'DataDog/datadog-agent' && github.event.pull_request.user.login == 'renovate[bot]' && contains(github.event.pull_request.labels.*.name, 'dependencies-bazel') }}
permissions:
Expand Down
262 changes: 261 additions & 1 deletion tasks/renovate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,86 @@
is either tracked by a Renovate ``customManager`` in ``renovate.json`` or listed
in ``deps/.renovate-untracked.json`` with a rationale. It runs in CI via
``.github/workflows/validate-renovate-deps.yml``.

The task ``refresh_archive_hashes``, is the companion to the
Renovate auto-bump flow for native deps pinned via ``http_archive(...)`` in
``deps/repos.MODULE.bazel``. Renovate updates the version literal but cannot
recompute the ``sha256`` field; this task downloads the new tarball, hashes
it, and rewrites the source. It is invoked from
``.github/workflows/bazel-native-tidy.yml``.

Design mirrors ``tasks/python_version.py::_prepare_bazel_update``: regex-based
edits with strict match-count validation so partial/silent failures abort the
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import urllib.error
import urllib.request
from pathlib import Path

from invoke import task
from invoke.context import Context
from invoke.exceptions import Exit
from invoke.tasks import task

REPO_ROOT = Path(__file__).resolve().parent.parent
MODULE_FILE = REPO_ROOT / "deps" / "repos.MODULE.bazel"

# Match each `http_archive(...)` block. Captures the block body so individual
# fields can be extracted with separate regexes. Anchored on column-0 `)` —
# matches the formatting convention used throughout deps/repos.MODULE.bazel.
ARCHIVE_BLOCK_RE = re.compile(
r"^http_archive\(\n(?P<body>.*?)^\)$",
re.MULTILINE | re.DOTALL,
)
NAME_RE = re.compile(r'^\s*name\s*=\s*"(?P<name>[^"]+)"', re.MULTILINE)
SHA256_RE = re.compile(r'^\s*sha256\s*=\s*"(?P<sha>[0-9a-fA-F]{64})"', re.MULTILINE)
# Used by ``_block_signature`` to strip the sha256 line regardless of value validity,
# so a stale/wrong hash doesn't preserve the signature when the version literal moved.
SHA256_LINE_RE = re.compile(r'^\s*sha256\s*=\s*"[^"]*",?\s*\n', re.MULTILINE)
# Capture plain URL string literals (no template placeholders).
URL_LITERAL_RE = re.compile(r'"(https?://[^"\s{}]+)"')
# Match top-level scalar assignments: `name = <expr>` not inside any block.
# Captures the variable name and the right-hand side expression.
_TOP_LEVEL_ASSIGN_RE = re.compile(r'^(\w+)\s*=\s*(.+)$', re.MULTILINE)
# Locate the start of a `url =` or `urls =` field within a block body.
_URL_FIELD_RE = re.compile(r'^\s*urls?\s*=\s*', re.MULTILINE)

# Safe builtins allowed when evaluating top-level Starlark scalar expressions.
# Starlark is a Python subset; these cover all constructs used in the file.
_SAFE_BUILTINS: dict = {"__builtins__": {}}


def _parse_top_level_namespace(text: str) -> dict:
"""Evaluate top-level scalar assignments from a MODULE.bazel file.

Walks the assignments in source order so later variables can reference
earlier ones (e.g. ``sqlite_amalgamation`` references ``sqlite_ver``).
Only simple expressions are evaluated — anything that raises is silently
skipped so a complex assignment never blocks resolution of simpler ones.

The returned namespace can be passed to ``_extract_urls`` to resolve URL
expressions that reference these variables.
"""
namespace: dict = {}
# Only consider lines that appear before the first http_archive/http_file
# call so we don't accidentally pick up block-internal assignments.
preamble_end = min(
(text.index(marker) for marker in ("http_archive(", "http_file(") if marker in text),
default=len(text),
)
preamble = text[:preamble_end]
for m in _TOP_LEVEL_ASSIGN_RE.finditer(preamble):
name, expr = m.group(1), m.group(2).strip()
try:
namespace[name] = eval(expr, {"__builtins__": {}}, namespace) # noqa: S307 — restricted namespace, preamble-only expressions
except Exception:
pass
return namespace


def main():
Expand Down Expand Up @@ -302,5 +368,199 @@ def _emit_failure_report(
return "\n".join(lines)


@task(
help={
"base_ref": "Git ref to compare against to detect changed http_archive blocks. "
"Defaults to origin/main, which suits the bazel-native-tidy workflow. "
"For local testing pass HEAD~1 or any other ref."
}
)
def refresh_archive_hashes(ctx: Context, base_ref: str = "origin/main") -> None:
"""
Recompute sha256 for any http_archive in deps/repos.MODULE.bazel whose
version literal differs from ``base_ref``.

Used by ``.github/workflows/bazel-native-tidy.yml`` after Renovate bumps a
version. Renovate cannot refresh ``sha256`` itself; this task downloads the
new tarball from the first reachable URL, hashes it, and rewrites the
source so the next Bazel build verifies cleanly.
"""
current_text = MODULE_FILE.read_text()
current_blocks = _parse_archive_blocks(current_text)
namespace = _parse_top_level_namespace(current_text)

result = ctx.run(f"git show {base_ref}:deps/repos.MODULE.bazel", hide=True, warn=True)
if not result.ok:
raise Exit(f"Could not read deps/repos.MODULE.bazel at {base_ref!r}: {result.stderr.strip()}")
previous_blocks = _parse_archive_blocks(result.stdout)

needs_refresh: list[str] = []
for name, body in current_blocks.items():
prev = previous_blocks.get(name)
if prev is None:
# New http_archive added in this PR — initial sha256 is the human's job.
continue
if _block_signature(body) != _block_signature(prev):
needs_refresh.append(name)

if not needs_refresh:
print("No http_archive blocks need sha256 refresh.")
return

print(f"Refreshing sha256 for {len(needs_refresh)} block(s): {', '.join(needs_refresh)}")
new_text = current_text
for name in needs_refresh:
body = _parse_archive_blocks(new_text)[name]
urls = _extract_urls(body, namespace)
if not urls:
print(f" ! {name}: skipping — no literal URL found in block")
continue
old_sha = _extract_sha256(body)
print(f" → {name}: downloading from {urls[0]}")
new_sha = _download_and_hash(urls)
if new_sha == old_sha:
print(f" sha256 unchanged ({old_sha[:12]}...)")
continue
new_text = _replace_sha256_in_block(new_text, name, new_sha)
print(f" sha256 {old_sha[:12]}... -> {new_sha[:12]}...")

if new_text != current_text:
MODULE_FILE.write_text(new_text)
print(f"Updated {MODULE_FILE.relative_to(REPO_ROOT)}.")
else:
print("No sha256 values changed.")


def _parse_archive_blocks(text: str) -> dict[str, str]:
"""Return a mapping of http_archive name -> raw block body."""
blocks: dict[str, str] = {}
for m in ARCHIVE_BLOCK_RE.finditer(text):
body = m.group("body")
name_match = NAME_RE.search(body)
if name_match:
blocks[name_match.group("name")] = body
return blocks


def _block_signature(body: str) -> str:
"""Identity of a block excluding its sha256 — used to detect version bumps."""
return SHA256_LINE_RE.sub("", body)


def _extract_urls(body: str, namespace: dict | None = None) -> list[str]:
"""Return URLs found in the block, in order of appearance.

First tries plain string literals (no ``{}`` placeholders). If none are
found and a ``namespace`` is provided (populated by
``_parse_top_level_namespace``), locates each ``url``/``urls`` field,
extracts its full right-hand-side expression using bracket-depth tracking,
and evaluates it in the namespace to resolve variable references and
``.format()`` calls. This covers blocks whose URLs are constructed from
top-level variables (e.g. sqlite3).
"""
literals = URL_LITERAL_RE.findall(body)
if literals:
return literals
if namespace is None:
return []
urls: list[str] = []
for m in _URL_FIELD_RE.finditer(body):
expr = _extract_balanced_expr(body, m.end())
if expr is None:
continue
try:
result = eval(expr, {"__builtins__": {}}, namespace) # noqa: S307 — namespace is preamble-only scalars
except Exception:
continue
if isinstance(result, str) and result.startswith("http"):
urls.append(result)
elif isinstance(result, list | tuple):
urls.extend(u for u in result if isinstance(u, str) and u.startswith("http"))
return urls


def _extract_balanced_expr(text: str, start: int) -> str | None:
"""Extract a Starlark expression starting at ``start``, respecting bracket depth.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered using this? https://github.com/inducer/starlark-pyo3


Reads until the expression ends: either at a top-level comma/newline (for
simple scalar values) or after the matching closing bracket (for lists and
tuples). Returns the stripped expression string, or ``None`` if ``start``
is out of range.
"""
if start >= len(text):
return None
depth = 0
i = start
while i < len(text):
c = text[i]
if c in "([":
depth += 1
elif c in ")]":
depth -= 1
if depth < 0:
# Stepped outside the enclosing block — stop before this char.
break
elif c in ('"', "'"):
# Skip string literals so brackets inside them don't affect depth.
quote = c
i += 1
while i < len(text) and text[i] != quote:
if text[i] == "\\":
i += 1
i += 1
elif c == "," and depth == 0:
break
elif c == "\n" and depth == 0:
break
i += 1
return text[start:i].strip() or None


def _extract_sha256(body: str) -> str | None:
m = SHA256_RE.search(body)
return m.group("sha") if m else None


def _download_and_hash(urls: list[str]) -> str:
"""Try each URL in order; return the sha256 of the first that downloads cleanly."""
last_err: Exception | None = None
for url in urls:
try:
with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310 — URLs come from the source file we're checking
hasher = hashlib.sha256()
while chunk := resp.read(1 << 20):
hasher.update(chunk)
return hasher.hexdigest()
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
last_err = exc
print(f" fetch failed: {url} ({exc})")
continue
raise Exit(f"All URLs failed; last error: {last_err}")


def _replace_sha256_in_block(text: str, archive_name: str, new_sha: str) -> str:
"""Rewrite the sha256 line for the named http_archive block."""

# Locate the block, then replace its sha256 in a single substitution scoped
# to that block. Match-count validation guards against silent partial edits.
def repl(match: re.Match[str]) -> str:
body = match.group("body")
if not NAME_RE.search(body) or NAME_RE.search(body).group("name") != archive_name:
return match.group(0)
new_body, count = SHA256_RE.subn(
lambda _: f' sha256 = "{new_sha}"',
body,
count=1,
)
if count != 1:
raise Exit(f"Expected 1 sha256 line in http_archive(name={archive_name!r}), found {count}")
return f"http_archive(\n{new_body})"

new_text, count = ARCHIVE_BLOCK_RE.subn(repl, text, count=0)
if count == 0:
raise Exit(f"Could not locate http_archive block for {archive_name!r}")
return new_text


if __name__ == "__main__":
main()
Loading
Loading