diff --git a/.github/workflows/release-dispatch.yml b/.github/workflows/release-dispatch.yml new file mode 100644 index 0000000000000..ae3ff563d16d2 --- /dev/null +++ b/.github/workflows/release-dispatch.yml @@ -0,0 +1,99 @@ +name: Dispatch Release + +# Reusable workflow — intended to be called by a release-trigger workflow in an +# integration repository (integrations-core, integrations-extras, marketplace). + +on: + workflow_call: + inputs: + source-repo: + description: "Source repository name (e.g. integrations-core, integrations-extras, marketplace)" + required: false + type: string + packages: + description: >- + Packages to release. Accepts a JSON array (e.g. '["postgres","datadog_checks_base"]'), + 'all' to release every Python package in the repo, or omit to auto-detect from new tags. + required: false + type: string + source-repo-ref: + description: "Commit SHA or ref to build from" + required: false + type: string + target: + description: "Target environment (dev = no tag push, dev S3; prod = push tags, prod S3)" + required: false + type: string + default: prod # callers (e.g. release-trigger.yml) typically override this + dry-run: + description: >- + When true, print what would be released and where without pushing tags + or triggering downstream wheel builds. + required: false + type: boolean + default: false + ddev-version: + description: "ddev version, pinned by default." + required: false + type: string + is-stable-release: + description: "'true' for master/X.X.x (blocks pre-releases), 'false' for alpha/beta/rc (blocks stable). Unset defaults to stable behavior." + required: false + type: string + +permissions: + id-token: write + contents: write # ddev needs to push tags + +jobs: + dispatch: + name: Tag releases and dispatch wheel builds + runs-on: ubuntu-latest + environment: release + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # ddev needs full tag history + + - name: Install ddev + run: pip install "ddev${{ inputs.ddev-version && format('=={0}', inputs.ddev-version) || '==14.3.2' }}" + + - name: Configure ddev + env: + SOURCE_REPO: ${{ inputs.source-repo || 'integrations-core' }} + run: | + REPO_SHORT="${SOURCE_REPO#integrations-}" + ddev config set upgrade_check false + ddev config set repos.${REPO_SHORT} . + ddev config set repo ${REPO_SHORT} + + - name: Prepare dispatch + id: prepare + env: + TARGET: ${{ inputs.target }} + DRY_RUN: ${{ inputs.dry-run }} + SELECTED_PACKAGES: ${{ inputs.packages }} + SOURCE_REPO: ${{ inputs.source-repo || 'integrations-core' }} + REF: ${{ inputs.source-repo-ref || github.sha }} + IS_STABLE_RELEASE: ${{ inputs.is-stable-release }} + run: python .github/workflows/scripts/release_prepare.py + + - name: Get GitHub token via dd-octo-sts + if: steps.prepare.outputs.has_packages == 'true' && !inputs.dry-run + id: octo-sts + uses: DataDog/dd-octo-sts-action@08f2144903ced3254a3dafec2592563409ba2aa0 # v1.0.1 + with: + scope: DataDog/agent-integration-wheels-release + policy: integrations-core.dispatch-wheel-builds + + - name: Dispatch release + if: steps.prepare.outputs.has_packages == 'true' + env: + GH_TOKEN: ${{ steps.octo-sts.outputs.token }} + PACKAGES: ${{ steps.prepare.outputs.packages }} + SOURCE_REPO: ${{ inputs.source-repo || 'integrations-core' }} + REF: ${{ inputs.source-repo-ref || github.sha }} + TARGET: ${{ inputs.target }} + DRY_RUN: ${{ inputs.dry-run }} + run: python .github/workflows/scripts/release_dispatch.py diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml new file mode 100644 index 0000000000000..2958f40573ca2 --- /dev/null +++ b/.github/workflows/release-trigger.yml @@ -0,0 +1,79 @@ +name: Trigger Wheel Builds + +# Thin wrapper — all tagging and dispatch logic lives in release-dispatch.yml. +# The pipeline can build wheels for any Python package in the repo. +# Equivalent trigger workflows exist in integrations-extras and marketplace. + +on: + push: + branches: + - master + - "[0-9]+.[0-9]+.x" + - "alpha/*" + - "beta/*" + - "rc/*" + paths: + - "*/CHANGELOG.md" + - "*/datadog_checks/*/__about__.py" + workflow_dispatch: + inputs: + packages: + description: >- + Packages to release. JSON array (e.g. '["postgres","datadog_checks_base"]'), + 'all' to release every Python package in the repo, or omit to auto-detect from new tags. + required: false + type: string + source-repo-ref: + description: "Commit SHA or ref to build from" + required: true + type: string + target: + description: "Target environment (dev or prod)" + required: false + type: choice + options: + - dev + - prod + default: dev + dry-run: + description: "Print what would be released without pushing tags or starting builds" + required: false + type: boolean + default: false + ddev-version: + description: "ddev version, pinned by default." + required: false + type: string + +jobs: + context: + name: Detect release context + runs-on: ubuntu-latest + outputs: + is-stable-release: ${{ steps.detect.outputs.is-stable-release }} + steps: + - id: detect + # Sets is-stable-release based on the branch: true for master/X.Y.x, false otherwise. + # Manual runs on master get "true", which blocks pre-release packages — conservative and intentional. + run: | + if [[ "$GITHUB_REF" =~ ^refs/heads/(master|[0-9]+\.[0-9]+\.x)$ ]]; then + echo "is-stable-release=true" >> "$GITHUB_OUTPUT" + else + echo "is-stable-release=false" >> "$GITHUB_OUTPUT" + fi + + dispatch: + name: Release + needs: context + uses: ./.github/workflows/release-dispatch.yml + with: + source-repo: integrations-core + packages: ${{ inputs.packages || '' }} + source-repo-ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source-repo-ref || github.sha }} + target: ${{ inputs.target || 'dev' }} + dry-run: ${{ inputs.dry-run || false }} + ddev-version: ${{ inputs.ddev-version || '' }} + is-stable-release: ${{ needs.context.outputs.is-stable-release }} + permissions: + id-token: write + contents: write diff --git a/.github/workflows/scripts/_release/__init__.py b/.github/workflows/scripts/_release/__init__.py new file mode 100644 index 0000000000000..8a0fe3aeeebea --- /dev/null +++ b/.github/workflows/scripts/_release/__init__.py @@ -0,0 +1 @@ +TARGET_REPO = "DataDog/agent-integration-wheels-release" diff --git a/.github/workflows/scripts/_release/dispatch.py b/.github/workflows/scripts/_release/dispatch.py new file mode 100644 index 0000000000000..23fed7572e985 --- /dev/null +++ b/.github/workflows/scripts/_release/dispatch.py @@ -0,0 +1,102 @@ +"""HTTP dispatch logic for repository_dispatch events.""" +import http.client +import json +import ssl +import sys +import time +import urllib.error +import urllib.request + +from . import TARGET_REPO + +BATCH_SIZE = 200 +DISPATCH_URL = f"https://api.github.com/repos/{TARGET_REPO}/dispatches" +MAX_ATTEMPTS = 5 + + +class DispatchError(Exception): + """Raised when a dispatch request fails after all retry attempts.""" + + +def build_payload(batch: list[str], source_repo: str, ref: str, target: str) -> dict: + """Return the ``repository_dispatch`` payload for one batch.""" + return { + "event_type": "build-wheels", + "client_payload": { + "packages": batch, + "source_repo": source_repo, + "source_repo_ref": ref, + "target": target, + }, + } + + +def _urlopen(req: urllib.request.Request) -> http.client.HTTPResponse: + """Thin urllib wrapper — exists so tests can patch it without touching stdlib.""" + return urllib.request.urlopen(req) + + +def send_dispatch( + payload: dict, + token: str, + *, + dispatch_url: str = DISPATCH_URL, + max_attempts: int = MAX_ATTEMPTS, +) -> None: + """POST a single ``repository_dispatch`` event to the wheels-release repo. + + Retries up to ``max_attempts`` times on 5xx errors with exponential backoff. + Raises ``DispatchError`` on 4xx errors or after exhausting retries. + """ + req = urllib.request.Request( + dispatch_url, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + method="POST", + ) + for attempt in range(1, max_attempts + 1): + try: + with _urlopen(req) as resp: + print(f" Dispatched: HTTP {resp.status}") + return + except (urllib.error.HTTPError, urllib.error.URLError) as e: + if isinstance(e, urllib.error.URLError): + if isinstance(e.reason, ssl.SSLError): + raise DispatchError(f"SSL error (non-retriable): {e}") from e + body = str(e) + code = 503 + else: + body = e.read().decode() + code = e.code + if code < 500 or attempt == max_attempts: + print(f"HTTP {code}: {body}", file=sys.stderr) + raise DispatchError(f"HTTP {code}: {body}") + print(f" HTTP {code} on attempt {attempt}/{max_attempts}, retrying...", file=sys.stderr) + time.sleep(2**attempt) + + +def dispatch_in_batches( + packages: list[str], + source_repo: str, + ref: str, + target: str, + token: str, + batch_size: int = BATCH_SIZE, +) -> None: + """Dispatch all packages to the wheels-release repo, batching if needed.""" + if batch_size <= 0: + raise ValueError("batch_size must be > 0") + num_packages = len(packages) + if num_packages == 0: + return + total_batches = (num_packages + batch_size - 1) // batch_size + for batch_num, start in enumerate(range(0, num_packages, batch_size), 1): + end = min(start + batch_size, num_packages) + current_batch = packages[start:end] + print(f"\nBatch {batch_num}/{total_batches}:") + print("\n".join(f" - {name}" for name in current_batch)) + send_dispatch(build_payload(current_batch, source_repo, ref, target), token) diff --git a/.github/workflows/scripts/_release/github.py b/.github/workflows/scripts/_release/github.py new file mode 100644 index 0000000000000..ec711460472bc --- /dev/null +++ b/.github/workflows/scripts/_release/github.py @@ -0,0 +1,36 @@ +"""GitHub Actions I/O helpers.""" +import os +from pathlib import Path + + +def set_outputs(**kwargs: str) -> None: + """Write key=value pairs to GITHUB_OUTPUT.""" + path = os.environ.get("GITHUB_OUTPUT") + if path: + with Path(path).open("a") as f: + for key, value in kwargs.items(): + f.write(f"{key}={value}\n") + + +def write_summary(content: str) -> None: + """Append markdown content to GITHUB_STEP_SUMMARY.""" + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with Path(path).open("a") as f: + f.write(content + "\n") + + +def parse_bool_env(name: str, default: bool = False) -> bool: + """Parse a boolean environment variable. + + Accepts 'true'/'1'/'yes' as True and 'false'/'0'/'no' as False (case-insensitive). + Returns ``default`` when the variable is absent or empty. + """ + val = os.environ.get(name, "").strip().lower() + if not val: + return default + if val in ("true", "1", "yes"): + return True + if val in ("false", "0", "no"): + return False + return default diff --git a/.github/workflows/scripts/_release/packages.py b/.github/workflows/scripts/_release/packages.py new file mode 100644 index 0000000000000..95595a9366ede --- /dev/null +++ b/.github/workflows/scripts/_release/packages.py @@ -0,0 +1,69 @@ +"""Package detection and resolution logic.""" +import json +import re +import subprocess +from pathlib import Path + +_VERSION_SUFFIX_RE = re.compile(r"-\d+\.\d+\.\d+.*$") + + +def get_all_packages(root: Path = Path(".")) -> list[str]: + """Return sorted list of all Python packages found under *root*.""" + return sorted( + {p.parent.parent.parent.name for p in root.glob("*/datadog_checks/*/__about__.py")} + ) + + +def get_tags_at_head() -> list[str]: + """Return the list of git tags pointing at HEAD.""" + try: + return subprocess.check_output(["git", "tag", "--points-at", "HEAD"], text=True).splitlines() + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to get git tags at HEAD: {e}") from e + + +def detect_from_tags(tags: list[str]) -> list[str]: + """Return sorted unique package names extracted from release tags. + + Tags are expected in the form ``-X.Y.Z[...]``. + The version suffix is stripped to recover the package name. + """ + return sorted({_VERSION_SUFFIX_RE.sub("", t) for t in tags if t.strip()}) + + +def resolve_packages( + selected: str, + all_packages: list[str], + head_tags: list[str] | None = None, +) -> tuple[list[str], str]: + """Resolve the list of packages to release. + + Resolution order: + - ``'all'`` / ``'ALL'`` → every package in the repo + - JSON array → use the provided list verbatim + - empty string → auto-detect from git tags at HEAD + + Returns ``(packages, mode_description)``. + Raises ``ValueError`` on invalid input or unknown package names. + """ + selected = selected.strip() + + if selected.lower() == "all": + return all_packages, f"all ({len(all_packages)} packages in repo)" + + if selected: + try: + packages = json.loads(selected) + except json.JSONDecodeError as e: + raise ValueError(f"SELECTED_PACKAGES is not valid JSON: {e}") from e + mode = f"manual ({selected})" + else: + tags = head_tags if head_tags is not None else get_tags_at_head() + packages = detect_from_tags(tags) + mode = "auto-detect from tags at HEAD" + + unknown = sorted(set(packages) - set(all_packages)) + if unknown: + raise ValueError(f"Unknown packages: {', '.join(unknown)}") + + return packages, mode diff --git a/.github/workflows/scripts/_release/summary.py b/.github/workflows/scripts/_release/summary.py new file mode 100644 index 0000000000000..e83b5c608762f --- /dev/null +++ b/.github/workflows/scripts/_release/summary.py @@ -0,0 +1,86 @@ +"""Markdown summary builder for the wheel release pipeline.""" +from typing import Any + +from . import TARGET_REPO +from . import validation as v + +ACTIONS_URL = f"https://github.com/{TARGET_REPO}/actions" + + +def _source_link(source_repo: str, ref: str) -> str: + if ref: + return f"[`{source_repo}@{ref[:12]}`](https://github.com/DataDog/{source_repo}/commit/{ref})" + return source_repo + + +def _ineligible_label(typ: str) -> str: + if typ == v.STABLE: + return "❌ Stable release blocked (pre-release branch)" + if typ == v.PRE_RELEASE: + return "⏭️ Pre-release skipped (stable branch)" + if typ == v.NO_VERSION: + return "⚠️ No version found" + if typ == v.UNRELEASED: + return "⏭️ Placeholder version (0.0.1)" + if typ == v.HAS_FRAGMENTS: + return "❌ Pending changelog fragments" + raise ValueError(f"unexpected validation type: {typ!r}") + + +def build_summary( + packages: list[str], + results: list[Any], + mode: str, + source_repo: str, + ref: str, + target: str, + dry_run: bool, + was_dispatched: bool, + footer: str = "", +) -> str: + """Return the full Markdown summary for GITHUB_STEP_SUMMARY. + + Args: + packages: Ordered list of package names to include in the table. + results: Validation result dicts (``{"package", "version", "type", "dispatch"}``). + mode: Human-readable detection mode string. + source_repo: Repository name (e.g. ``integrations-core``). + ref: Commit SHA or ref used as the build source. + target: Deployment target (``dev`` or ``prod``). + dry_run: Whether this was a dry run. + was_dispatched: Whether wheel-build events were actually dispatched. + footer: Optional extra paragraph appended after the package table. + """ + by_package = {r["package"]: r for r in results} + + rows = [] + for name in packages: + r = by_package.get(name, {}) + ver = r.get("version") or "—" + typ = r.get("type", v.STABLE) + eligible = r.get("dispatch", True) + if eligible: + label = "🔄 Dry run" if dry_run else ("✅ Dispatched" if was_dispatched else "✅ Validated") + else: + label = _ineligible_label(typ) + rows.append(f"| `{name}` | `{ver}` | {label} |") + + if not footer: + if was_dispatched: + footer = f"[Track downstream runs →]({ACTIONS_URL}?query=event:repository_dispatch)" + elif dry_run: + footer = "> Dry run — no tags pushed, no builds triggered" + + return ( + "## Wheel Release\n\n" + "| | |\n|---|---|\n" + f"| **Mode** | {mode} |\n" + f"| **Source** | {_source_link(source_repo, ref)} |\n" + f"| **Target** | {target} S3 |\n" + f"| **Dry run** | {'Yes' if dry_run else 'No'} |\n\n" + "| Package | Version | Status |\n" + "|---------|---------|--------|\n" + + "\n".join(rows) + + (f"\n\n{footer}" if footer else "") + + "\n" + ) diff --git a/.github/workflows/scripts/_release/validation.py b/.github/workflows/scripts/_release/validation.py new file mode 100644 index 0000000000000..db1e9fa23e259 --- /dev/null +++ b/.github/workflows/scripts/_release/validation.py @@ -0,0 +1,73 @@ +"""Release readiness validation logic.""" +import re +from pathlib import Path +from typing import TypedDict + +_PRE_RELEASE_RE = re.compile(r"\d+\.\d+\.\d+(a|b|rc)\d+") +# Changelog fragment files are named . (e.g. "1234.fixed"). +# This pattern intentionally excludes .gitkeep, README.md, and other non-fragment files. +_FRAGMENT_RE = re.compile(r"^\d+\.[a-z]+$") + +class PackageValidationResult(TypedDict): + package: str + version: str | None + type: str + dispatch: bool + + +NO_VERSION = "no_version" +UNRELEASED = "unreleased" +PRE_RELEASE = "pre_release" +HAS_FRAGMENTS = "has_fragments" +STABLE = "stable" + + +def get_version(package: str, root: Path = Path(".")) -> str | None: + """Return the ``__version__`` string from the package's ``__about__.py``, or None.""" + about = next((root / package).glob("datadog_checks/*/__about__.py"), None) + if about is None: + return None + match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', about.read_text()) + return match.group(1) if match else None + + +def is_pre_release(version: str) -> bool: + """Return True if *version* contains a pre-release marker (a/b/rc).""" + return bool(_PRE_RELEASE_RE.search(version)) + + +def has_changelog_fragments(package: str, root: Path = Path(".")) -> bool: + """Return True if ``changelog.d/`` contains unreleased fragment files. + + Only files matching ``.`` are counted as fragments, so + housekeeping files like ``.gitkeep`` or ``README.md`` are ignored. + """ + changelog_dir = root / package / "changelog.d" + if not changelog_dir.is_dir(): + return False + return any(_FRAGMENT_RE.match(f.name) for f in changelog_dir.iterdir()) + + +def validate_package(package: str, root: Path = Path("."), is_stable_release: bool = True) -> PackageValidationResult: + """Validate a single package and return a result dict with ``type`` and ``dispatch`` keys. + + ``is_stable_release`` encodes the git ref context: + - ``True`` (master/X.X.x/...): pre-release versions are not dispatched. + - ``False`` (alpha/beta/rc): stable versions are not dispatched. + """ + version = get_version(package, root) + if version is None: + return {"package": package, "version": None, "type": NO_VERSION, "dispatch": False} + if version == "0.0.1": + return {"package": package, "version": version, "type": UNRELEASED, "dispatch": False} + if is_pre_release(version): + # PEP 440 pre-release (alpha/beta/rc) — skip fragment check, dispatch unless on a stable branch + return {"package": package, "version": version, "type": PRE_RELEASE, "dispatch": not is_stable_release} + if has_changelog_fragments(package, root): + return {"package": package, "version": version, "type": HAS_FRAGMENTS, "dispatch": False} + return {"package": package, "version": version, "type": STABLE, "dispatch": is_stable_release} + + +def validate_packages(packages: list[str], root: Path = Path("."), is_stable_release: bool = True) -> list[PackageValidationResult]: + """Validate all packages and return a list of result dicts.""" + return [validate_package(p, root, is_stable_release) for p in packages] diff --git a/.github/workflows/scripts/release_dispatch.py b/.github/workflows/scripts/release_dispatch.py new file mode 100644 index 0000000000000..4a171fe1db9ce --- /dev/null +++ b/.github/workflows/scripts/release_dispatch.py @@ -0,0 +1,67 @@ +"""Dispatch build-wheels events to agent-integration-wheels-release in batches. + +Environment variables: GH_TOKEN, PACKAGES, SOURCE_REPO, REF, TARGET, DRY_RUN. +""" +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from _release.dispatch import DispatchError, dispatch_in_batches +from _release.github import parse_bool_env, write_summary +from _release.summary import build_summary + + +def _load_validation(runner_temp: str) -> dict: + path = Path(runner_temp) / "release_validation.json" + try: + return json.loads(path.read_text()) + except FileNotFoundError: + print( + f"Error: validation results not found at {path}. " + "The prepare step likely failed before writing the file.", + file=sys.stderr, + ) + sys.exit(1) + except json.JSONDecodeError as e: + print( + f"Warning: validation results at {path} are not valid JSON: {e}. Summary may be incomplete.", + file=sys.stderr, + ) + return {} + + +def main() -> None: + packages = json.loads(os.environ["PACKAGES"]) + source_repo = os.environ["SOURCE_REPO"] + ref = os.environ["REF"] + target = os.environ["TARGET"] + + dry_run = parse_bool_env("DRY_RUN", default=False) + + validation = _load_validation(os.environ.get("RUNNER_TEMP", "/tmp")) + results = validation.get("results", []) + mode = validation.get("mode", "") + + print(f"Releasing {len(packages)} package(s) from {source_repo}@{ref} → {target} S3:") + + if dry_run: + for name in packages: + print(f" - {name}") + print("\nDRY RUN: no tags pushed, no builds triggered") + write_summary(build_summary(packages, results, mode, source_repo, ref, target, dry_run, was_dispatched=False)) + return + + token = os.environ["GH_TOKEN"] + try: + dispatch_in_batches(packages, source_repo, ref, target, token) + except DispatchError: + sys.exit(1) + + write_summary(build_summary(packages, results, mode, source_repo, ref, target, dry_run, was_dispatched=True)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/release_prepare.py b/.github/workflows/scripts/release_prepare.py new file mode 100644 index 0000000000000..2c11ac4d57120 --- /dev/null +++ b/.github/workflows/scripts/release_prepare.py @@ -0,0 +1,224 @@ +"""Prepare a release: tag packages, detect the release list, and validate readiness. + +Environment variables: + TARGET 'prod' pushes tags; 'dev' (default) creates them locally only + DRY_RUN When true, tags are created locally but never pushed + SELECTED_PACKAGES JSON array of packages to tag, 'all', or empty to tag all + SOURCE_REPO Source repository name (integrations-core, integrations-extras, marketplace) + REF Commit SHA or ref to build from + IS_STABLE_RELEASE 'false' for alpha/beta/rc branches; anything else (default) → stable behavior +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from _release.github import parse_bool_env, set_outputs, write_summary +from _release.packages import get_all_packages, resolve_packages +from _release.summary import build_summary +from _release.validation import HAS_FRAGMENTS, NO_VERSION, PRE_RELEASE, STABLE, UNRELEASED, validate_packages + + +def _package_args(selected: str) -> list[str]: + """Return the package arguments for ``ddev release tag``.""" + selected = selected.strip() + if not selected or selected.lower() == "all": + return ["all"] + try: + packages = json.loads(selected) + except json.JSONDecodeError as e: + print(f"SELECTED_PACKAGES is not valid JSON: {e}", file=sys.stderr) + sys.exit(1) + return packages if packages else ["all"] + + +def _parse_is_stable_release() -> bool: + """Parse IS_STABLE_RELEASE: 'false' → False, anything else (including unset) → True (stable).""" + return os.environ.get("IS_STABLE_RELEASE", "true").strip().lower() != "false" + + +def _tag(target: str, dry_run: bool, selected: str) -> None: + """Tag packages via ddev and optionally push to origin.""" + if dry_run: + print("DRY RUN: tags will be created locally but not pushed to origin") + elif target != "prod": + print(f"Target '{target}': tags will be created locally but not pushed to origin") + + subprocess.run(["git", "config", "user.name", "github-actions[bot]"], check=True) + subprocess.run( + ["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], + check=True, + ) + + push_flag = "--push" if (target == "prod" and not dry_run) else "--no-push" + base_cmd = ["ddev", "release", "tag"] + _package_args(selected) + ["--skip-prerelease", push_flag] + + result = subprocess.run(base_cmd) + if result.returncode == 3: + # ddev release tag exits 3 when the remote fetch fails (e.g. tags already cached locally). + # Retry with --no-fetch to use the local tag state. + result = subprocess.run(base_cmd + ["--no-fetch"]) + + # exit 2 means nothing new to tag — not an error, tags may already exist on HEAD + if result.returncode not in (0, 2): + print(f"ddev release tag failed with exit code {result.returncode}", file=sys.stderr) + sys.exit(result.returncode) + + +def _detect(selected: str) -> tuple[list[str], str]: + """Resolve the list of packages to release.""" + all_packages = get_all_packages() + try: + packages, mode = resolve_packages(selected, all_packages) + except (ValueError, RuntimeError) as e: + print(e, file=sys.stderr) + sys.exit(1) + + print(f"Mode: {mode}") + + if packages: + print(f"\nDetected {len(packages)} package(s) to release:") + for name in packages: + print(f" - {name}") + else: + print("No packages detected — nothing to release") + + return packages, mode + + +def _validate( + packages: list[str], + mode: str, + source_repo: str, + ref: str, + target: str, + dry_run: bool, + is_stable_release: bool, +) -> None: + """Validate that packages are ready to release.""" + if source_repo == "integrations-core": + result = subprocess.run(["ddev", "validate", "version"] + packages) + if result.returncode != 0: + sys.exit(result.returncode) + + results = validate_packages(packages, is_stable_release=is_stable_release) + + # Persist results for the dispatch step. + runner_temp = os.environ.get("RUNNER_TEMP", "/tmp") + Path(runner_temp, "release_validation.json").write_text( + json.dumps({"results": results, "mode": mode, "ref": ref, "target": target}) + ) + + by_type: dict[str, list] = {} + for r in results: + by_type.setdefault(r["type"], []).append(r) + + no_version = by_type.get(NO_VERSION, []) + unreleased = by_type.get(UNRELEASED, []) + errors = by_type.get(HAS_FRAGMENTS, []) + validated = [r for r in results if r["dispatch"]] + + if no_version: + print("Skipped (no version found):") + for r in no_version: + print(f" - {r['package']}") + + if unreleased: + print("Skipped (unreleased — 0.0.1 placeholder):") + for r in unreleased: + print(f" - {r['package']}") + + if is_stable_release: + skipped_pre_release = by_type.get(PRE_RELEASE, []) + if skipped_pre_release: + print("Skipped (pre-release version on stable branch):") + for r in skipped_pre_release: + print(f" - {r['package']} ({r['version']})") + + if not is_stable_release: + mismatched_stable = by_type.get(STABLE, []) + if mismatched_stable: + print("\nRelease validation failed — stable versions are not allowed on alpha/beta/rc branches:", file=sys.stderr) + for r in mismatched_stable: + print(f" {r['package']} ({r['version']})", file=sys.stderr) + write_summary( + build_summary( + packages, + results, + mode, + source_repo, + ref, + target, + dry_run, + was_dispatched=False, + footer="> ⚠️ Validation failed — stable versions cannot be released from an alpha/beta/rc branch.", + ) + ) + sys.exit(1) + + if errors: + print("\nRelease validation failed:", file=sys.stderr) + for r in errors: + print( + f" {r['package']} ({r['version']}): changelog.d/ contains unreleased fragments", + file=sys.stderr, + ) + print( + "\nRun 'ddev release make' to consolidate changelog fragments before releasing.", + file=sys.stderr, + ) + write_summary( + build_summary( + packages, + results, + mode, + source_repo, + ref, + target, + dry_run, + was_dispatched=False, + footer="> ⚠️ Validation failed — run `ddev release make` to consolidate changelog fragments before releasing.", + ) + ) + sys.exit(1) + + print(f"\nValidated {len(validated)} package(s):") + for r in validated: + suffix = " [pre-release]" if r["type"] == PRE_RELEASE else "" + print(f" - {r['package']} ({r['version']}){suffix}") + + +def main() -> None: + target = os.environ.get("TARGET", "dev") + dry_run = parse_bool_env("DRY_RUN", default=False) + selected = os.environ.get("SELECTED_PACKAGES", "") + source_repo = os.environ.get("SOURCE_REPO", "integrations-core") + ref = os.environ.get("REF", "") + is_stable_release = _parse_is_stable_release() + + # Normalise "[]" to empty string so both _tag and _detect treat it as auto-detect. + if selected.strip() == "[]": + selected = "" + + _tag(target, dry_run, selected) + + packages, mode = _detect(selected) + + set_outputs( + packages=json.dumps(packages), + has_packages="true" if packages else "false", + mode=mode, + ) + + if not packages: + write_summary("## Wheel Release\n\nNo packages detected — nothing to release.\n") + return + + _validate(packages, mode, source_repo, ref, target, dry_run, is_stable_release) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/tests/__init__.py b/.github/workflows/scripts/tests/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/.github/workflows/scripts/tests/test_dispatch.py b/.github/workflows/scripts/tests/test_dispatch.py new file mode 100644 index 0000000000000..038f7ab0f9c0b --- /dev/null +++ b/.github/workflows/scripts/tests/test_dispatch.py @@ -0,0 +1,214 @@ +"""Tests for _release.dispatch and _release.summary.""" +import http.client +import io +import urllib.error +from unittest.mock import call, create_autospec, patch + +import pytest + +from _release.dispatch import DispatchError, build_payload, dispatch_in_batches, send_dispatch +from _release.summary import build_summary, _ineligible_label +from _release import validation as v + + +class TestSendDispatch: + _payload = {"event_type": "build-wheels", "client_payload": {}} + _url = "https://example.com/dispatch" + + def _http_error(self, code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + "https://example.com", code, f"HTTP {code}", {}, io.BytesIO(b"error") + ) + + def test_exits_on_4xx(self): + with patch("_release.dispatch._urlopen", side_effect=self._http_error(422)), \ + pytest.raises(DispatchError): + send_dispatch(self._payload, "token", dispatch_url=self._url, max_attempts=1) + + def test_exits_after_5xx_exhausts_retries(self): + with patch("_release.dispatch._urlopen", side_effect=self._http_error(500)), \ + pytest.raises(DispatchError): + send_dispatch(self._payload, "token", dispatch_url=self._url, max_attempts=1) + + def test_succeeds_after_transient_5xx(self): + mock_ctx = create_autospec(http.client.HTTPResponse, instance=True) + mock_ctx.__enter__.return_value = mock_ctx + mock_ctx.status = 204 + with patch("_release.dispatch.time.sleep") as mock_sleep, \ + patch("_release.dispatch._urlopen", side_effect=[self._http_error(503), self._http_error(502), mock_ctx]): + send_dispatch(self._payload, "token", dispatch_url=self._url, max_attempts=3) + assert mock_sleep.call_args_list == [call(2), call(4)] # 2**1, 2**2 + + def test_retries_on_url_error(self): + url_error = urllib.error.URLError("Connection refused") + mock_ctx = create_autospec(http.client.HTTPResponse, instance=True) + mock_ctx.__enter__.return_value = mock_ctx + mock_ctx.status = 204 + with patch("_release.dispatch.time.sleep") as mock_sleep, \ + patch("_release.dispatch._urlopen", side_effect=[url_error, mock_ctx]): + send_dispatch(self._payload, "token", dispatch_url=self._url, max_attempts=3) + assert mock_sleep.call_args_list == [call(2)] # retried once + + def test_raises_on_url_error_after_exhausting_retries(self): + url_error = urllib.error.URLError("Connection refused") + with patch("_release.dispatch._urlopen", side_effect=url_error), \ + patch("_release.dispatch.time.sleep"), \ + pytest.raises(DispatchError): + send_dispatch(self._payload, "token", dispatch_url=self._url, max_attempts=2) + + def test_authorization_header_sent(self): + mock_ctx = create_autospec(http.client.HTTPResponse, instance=True) + mock_ctx.__enter__.return_value = mock_ctx + mock_ctx.status = 204 + with patch("_release.dispatch._urlopen", return_value=mock_ctx) as mock_urlopen: + send_dispatch(self._payload, "my-token", dispatch_url=self._url) + req = mock_urlopen.mock_calls[0].args[0] + assert req.get_header("Authorization") == "Bearer my-token" + + +class TestBuildPayload: + def test_structure(self): + payload = build_payload(["postgres", "mysql"], "integrations-core", "abc123", "prod") + assert payload["event_type"] == "build-wheels" + cp = payload["client_payload"] + assert cp["packages"] == ["postgres", "mysql"] + assert cp["source_repo"] == "integrations-core" + assert cp["source_repo_ref"] == "abc123" + assert cp["target"] == "prod" + + +class TestDispatchInBatches: + def test_single_batch(self): + packages = ["a", "b", "c"] + with patch("_release.dispatch.send_dispatch") as mock_send: + dispatch_in_batches(packages, "integrations-core", "sha1", "dev", "tok") + assert mock_send.mock_calls == [ + call(build_payload(packages, "integrations-core", "sha1", "dev"), "tok"), + ] + + def test_splits_into_batches(self): + packages = [f"pkg{i}" for i in range(8)] + with patch("_release.dispatch.send_dispatch") as mock_send: + dispatch_in_batches(packages, "repo", "ref", "prod", "tok", batch_size=3) + assert mock_send.mock_calls == [ + call(build_payload(packages[:3], "repo", "ref", "prod"), "tok"), + call(build_payload(packages[3:6], "repo", "ref", "prod"), "tok"), + call(build_payload(packages[6:], "repo", "ref", "prod"), "tok"), + ] + + def test_passes_token(self): + with patch("_release.dispatch.send_dispatch") as mock_send: + dispatch_in_batches(["pkg"], "repo", "ref", "dev", "my-token") + assert mock_send.mock_calls == [ + call(build_payload(["pkg"], "repo", "ref", "dev"), "my-token"), + ] + + def test_empty_packages_does_not_dispatch(self): + with patch("_release.dispatch.send_dispatch") as mock_send: + dispatch_in_batches([], "repo", "ref", "dev", "tok") + mock_send.assert_not_called() + + def test_invalid_batch_size_raises(self): + with pytest.raises(ValueError): + dispatch_in_batches(["pkg"], "repo", "ref", "dev", "tok", batch_size=0) + + +class TestBuildSummary: + _results = [ + {"package": "postgres", "version": "1.2.3", "type": v.STABLE, "dispatch": True}, + {"package": "mysql", "version": "2.0.0b1", "type": v.PRE_RELEASE, "dispatch": True}, + {"package": "redis", "version": None, "type": v.NO_VERSION, "dispatch": False}, + {"package": "pg", "version": "1.0.0", "type": v.HAS_FRAGMENTS, "dispatch": False}, + {"package": "new_pkg", "version": "0.0.1", "type": v.UNRELEASED, "dispatch": False}, + ] + + def _summary(self, packages=None, **kwargs): + defaults = dict( + mode="auto", + source_repo="integrations-core", + ref="abc1234567890", + target="prod", + dry_run=False, + was_dispatched=True, + ) + return build_summary(packages or ["postgres"], self._results, **{**defaults, **kwargs}) + + def test_labels_when_dispatched(self): + out = build_summary( + ["postgres", "mysql", "redis", "pg", "new_pkg"], + self._results, + mode="auto", + source_repo="integrations-core", + ref="sha", + target="prod", + dry_run=False, + was_dispatched=True, + ) + assert "✅ Dispatched" in out + assert "⏭️ Placeholder version" in out + assert "⚠️ No version" in out + assert "❌ Pending changelog" in out + + def test_pre_release_dry_run_label(self): + out = self._summary(packages=["mysql"], dry_run=True, was_dispatched=False) + assert "🔄 Dry run" in out + + def test_dry_run_label(self): + out = self._summary(dry_run=True, was_dispatched=False) + assert "🔄 Dry run" in out + + def test_custom_footer(self): + out = self._summary(was_dispatched=False, footer="> Custom footer text") + assert "Custom footer text" in out + + def test_ref_truncated_in_link(self): + full_sha = "a" * 40 + out = self._summary(ref=full_sha) + assert full_sha[:12] in out + assert full_sha[12:] not in out.split("commit/")[0] + + def test_stable_blocked_on_pre_release_branch(self): + results = [{"package": "postgres", "version": "1.2.3", "type": v.STABLE, "dispatch": False}] + out = build_summary( + ["postgres"], + results, + mode="auto", + source_repo="integrations-core", + ref="sha", + target="prod", + dry_run=False, + was_dispatched=False, + ) + assert "❌ Stable release blocked (pre-release branch)" in out + + def test_pre_release_skipped_on_stable_branch(self): + results = [{"package": "mysql", "version": "2.0.0b1", "type": v.PRE_RELEASE, "dispatch": False}] + out = build_summary( + ["mysql"], + results, + mode="auto", + source_repo="integrations-core", + ref="sha", + target="prod", + dry_run=False, + was_dispatched=False, + ) + assert "⏭️ Pre-release skipped (stable branch)" in out + + def test_eligible_not_dispatched(self): + results = [{"package": "postgres", "version": "1.2.3", "type": v.STABLE, "dispatch": True}] + out = build_summary( + ["postgres"], + results, + mode="auto", + source_repo="integrations-core", + ref="sha", + target="prod", + dry_run=False, + was_dispatched=False, + ) + assert "✅ Validated" in out + + def test_unknown_type_raises(self): + with pytest.raises(ValueError, match="unexpected validation type"): + _ineligible_label("bogus_type") diff --git a/.github/workflows/scripts/tests/test_github.py b/.github/workflows/scripts/tests/test_github.py new file mode 100644 index 0000000000000..94fe289c72b7d --- /dev/null +++ b/.github/workflows/scripts/tests/test_github.py @@ -0,0 +1,42 @@ +"""Tests for _release.github.""" +import pytest +from _release.github import parse_bool_env, set_outputs, write_summary + + +class TestParseBoolEnv: + @pytest.mark.parametrize("val", ["true", "True", "TRUE", "1", "yes", "YES"]) + def test_true_values(self, monkeypatch, val): + monkeypatch.setenv("MY_FLAG", val) + assert parse_bool_env("MY_FLAG") is True + + @pytest.mark.parametrize("val", ["false", "False", "FALSE", "0", "no", "NO"]) + def test_false_values(self, monkeypatch, val): + monkeypatch.setenv("MY_FLAG", val) + assert parse_bool_env("MY_FLAG") is False + + def test_missing_or_empty_uses_default(self, monkeypatch): + monkeypatch.delenv("MY_FLAG", raising=False) + assert parse_bool_env("MY_FLAG", default=False) is False + monkeypatch.setenv("MY_FLAG", "") + assert parse_bool_env("MY_FLAG", default=True) is True + + +class TestSetOutputs: + def test_writes_key_value_pairs(self, tmp_path, monkeypatch): + out = tmp_path / "output" + out.write_text("") + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + set_outputs(foo="bar", baz="qux") + content = out.read_text() + assert "foo=bar\n" in content + assert "baz=qux\n" in content + + +class TestWriteSummary: + def test_appends_content(self, tmp_path, monkeypatch): + summary = tmp_path / "summary.md" + summary.write_text("# Existing\n") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + write_summary("## New Section") + assert "## New Section" in summary.read_text() + assert "# Existing" in summary.read_text() diff --git a/.github/workflows/scripts/tests/test_packages.py b/.github/workflows/scripts/tests/test_packages.py new file mode 100644 index 0000000000000..7129c15302cf8 --- /dev/null +++ b/.github/workflows/scripts/tests/test_packages.py @@ -0,0 +1,80 @@ +"""Tests for _release.packages.""" +import pytest +from _release.packages import (detect_from_tags, get_all_packages, + resolve_packages) + + +class TestGetAllPackages: + def test_finds_packages(self, tmp_path): + for name in ("postgres", "mysql", "redis"): + about = tmp_path / name / "datadog_checks" / name / "__about__.py" + about.parent.mkdir(parents=True) + about.write_text('__version__ = "1.0.0"\n') + + result = get_all_packages(tmp_path) + assert result == ["mysql", "postgres", "redis"] + + def test_empty_repo(self, tmp_path): + assert get_all_packages(tmp_path) == [] + + +class TestDetectFromTags: + def test_strips_version_suffix(self): + tags = ["postgres-1.2.3", "mysql-0.1.0", "redis-10.0.0"] + assert detect_from_tags(tags) == ["mysql", "postgres", "redis"] + + def test_deduplicates(self): + tags = ["postgres-1.2.3", "postgres-1.2.4"] + assert detect_from_tags(tags) == ["postgres"] + + def test_ignores_blank_lines(self): + tags = ["postgres-1.2.3", "", " ", "mysql-2.0.0"] + assert detect_from_tags(tags) == ["mysql", "postgres"] + + def test_empty_list(self): + assert detect_from_tags([]) == [] + + def test_pre_release_version_stripped(self): + tags = ["datadog_checks_base-7.0.0rc1"] + assert detect_from_tags(tags) == ["datadog_checks_base"] + + def test_strips_only_version_suffix(self): + tags = ["amazon-msk-1.2.3"] + assert detect_from_tags(tags) == ["amazon-msk"] + + +class TestResolvePackages: + all_packages = ["mysql", "postgres", "redis"] + + def test_all_keyword(self): + packages, mode = resolve_packages(selected="all", all_packages=self.all_packages) + assert packages == self.all_packages + assert mode == f"all ({len(self.all_packages)} packages in repo)" + + def test_all_keyword_case_insensitive(self): + packages, _ = resolve_packages(selected="ALL", all_packages=self.all_packages) + assert packages == self.all_packages + + def test_json_array(self): + packages, mode = resolve_packages('["postgres","mysql"]', all_packages=self.all_packages) + assert packages == ["postgres", "mysql"] + assert mode == 'manual (["postgres","mysql"])' + + def test_auto_detect(self): + tags = ["postgres-1.0.0", "redis-2.0.0"] + packages, mode = resolve_packages(selected="", all_packages=self.all_packages, head_tags=tags) + assert packages == ["postgres", "redis"] + assert mode == "auto-detect from tags at HEAD" + + def test_unknown_package_raises(self): + with pytest.raises(ValueError, match="Unknown packages"): + resolve_packages('["unknown_pkg"]', all_packages=self.all_packages) + + def test_invalid_json_raises(self): + with pytest.raises(ValueError, match="SELECTED_PACKAGES is not valid JSON"): + resolve_packages(selected="not-json", all_packages=self.all_packages) + + def test_unknown_in_auto_detect_raises(self): + tags = ["not-a-package-1.0.0"] + with pytest.raises(ValueError, match="Unknown packages"): + resolve_packages(selected="", all_packages=self.all_packages, head_tags=tags) diff --git a/.github/workflows/scripts/tests/test_script_release_dispatch.py b/.github/workflows/scripts/tests/test_script_release_dispatch.py new file mode 100644 index 0000000000000..8d3e59709c070 --- /dev/null +++ b/.github/workflows/scripts/tests/test_script_release_dispatch.py @@ -0,0 +1,88 @@ +"""Tests for release_dispatch entry-point script.""" +import json +from pathlib import Path +from unittest.mock import call, patch + +import pytest + +import release_dispatch + + +def _write_validation(path: Path) -> None: + data = { + "results": [], + "mode": "auto", + "ref": "abc123", + "target": "prod", + } + path.write_text(json.dumps(data)) + + +class TestLoadValidation: + def test_file_present_returns_dict(self, tmp_path): + validation_file = tmp_path / "release_validation.json" + _write_validation(validation_file) + result = release_dispatch._load_validation(str(tmp_path)) + assert result["mode"] == "auto" + + def test_file_absent_exits(self, tmp_path, capsys): + with pytest.raises(SystemExit) as exc_info: + release_dispatch._load_validation(str(tmp_path)) + assert exc_info.value.code == 1 + assert "Error" in capsys.readouterr().err + + def test_invalid_json_returns_empty_dict(self, tmp_path, capsys): + (tmp_path / "release_validation.json").write_text("not-valid-json{") + result = release_dispatch._load_validation(str(tmp_path)) + assert result == {} + assert "Warning" in capsys.readouterr().err + + +class TestMain: + def _base_env(self, monkeypatch, tmp_path, dry_run: bool = False, write_validation: bool = True): + runner_temp = tmp_path / "runner_temp" + runner_temp.mkdir() + if write_validation: + _write_validation(runner_temp / "release_validation.json") + + summary_file = tmp_path / "summary" + + monkeypatch.setenv("DRY_RUN", "true" if dry_run else "false") + monkeypatch.setenv("PACKAGES", '["postgres", "mysql"]') + monkeypatch.setenv("SOURCE_REPO", "integrations-core") + monkeypatch.setenv("REF", "abc123") + monkeypatch.setenv("TARGET", "prod") + monkeypatch.setenv("GH_TOKEN", "test-token") + monkeypatch.setenv("RUNNER_TEMP", str(runner_temp)) + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_file)) + return runner_temp, summary_file + + def test_dry_run_does_not_dispatch(self, monkeypatch, tmp_path): + self._base_env(monkeypatch, tmp_path, dry_run=True) + with patch("release_dispatch.dispatch_in_batches") as mock_dispatch, \ + patch("release_dispatch.write_summary"): + release_dispatch.main() + mock_dispatch.assert_not_called() + + def test_dry_run_writes_summary(self, monkeypatch, tmp_path): + self._base_env(monkeypatch, tmp_path, dry_run=True) + with patch("release_dispatch.dispatch_in_batches"), \ + patch("release_dispatch.write_summary") as mock_summary: + release_dispatch.main() + mock_summary.assert_called_once() + + def test_non_dry_run_calls_dispatch_in_batches(self, monkeypatch, tmp_path): + self._base_env(monkeypatch, tmp_path, dry_run=False) + with patch("release_dispatch.dispatch_in_batches") as mock_dispatch, \ + patch("release_dispatch.write_summary"): + release_dispatch.main() + assert mock_dispatch.mock_calls == [ + call(["postgres", "mysql"], "integrations-core", "abc123", "prod", "test-token"), + ] + + def test_missing_validation_file_exits(self, monkeypatch, tmp_path, capsys): + self._base_env(monkeypatch, tmp_path, write_validation=False) + with pytest.raises(SystemExit) as exc_info: + release_dispatch.main() + assert exc_info.value.code == 1 + assert "Error" in capsys.readouterr().err diff --git a/.github/workflows/scripts/tests/test_script_release_prepare.py b/.github/workflows/scripts/tests/test_script_release_prepare.py new file mode 100644 index 0000000000000..9d17614eafd0a --- /dev/null +++ b/.github/workflows/scripts/tests/test_script_release_prepare.py @@ -0,0 +1,328 @@ +"""Tests for release_prepare entry-point script.""" +import json +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +import release_prepare +from _release.validation import HAS_FRAGMENTS, STABLE + + +def _stable_result(package: str = "postgres", dispatch: bool = True) -> list[dict]: + return [{"package": package, "version": "1.0.0", "type": STABLE, "dispatch": dispatch}] + + +# --------------------------------------------------------------------------- +# _package_args +# --------------------------------------------------------------------------- + +class TestPackageArgs: + @pytest.mark.parametrize("selected, expected", [ + ("", ["all"]), + ("all", ["all"]), + ("ALL", ["all"]), + ('["postgres","mysql"]', ["postgres", "mysql"]), + ("[]", ["all"]), + ]) + def test_returns_expected(self, selected, expected): + assert release_prepare._package_args(selected) == expected + + def test_invalid_json_exits(self): + with pytest.raises(SystemExit): + release_prepare._package_args("invalid-json") + + +# --------------------------------------------------------------------------- +# _parse_is_stable_release +# --------------------------------------------------------------------------- + +class TestParseIsStableRelease: + @pytest.mark.parametrize("env_val, expected", [ + ("false", False), + ("true", True), + ("TRUE", True), + (None, True), # unset → defaults to True + ]) + def test_parse(self, monkeypatch, env_val, expected): + if env_val is None: + monkeypatch.delenv("IS_STABLE_RELEASE", raising=False) + else: + monkeypatch.setenv("IS_STABLE_RELEASE", env_val) + assert release_prepare._parse_is_stable_release() is expected + + +# --------------------------------------------------------------------------- +# _tag +# --------------------------------------------------------------------------- + +class TestTag: + _GIT_NAME = call(["git", "config", "user.name", "github-actions[bot]"], check=True) + _GIT_EMAIL = call( + ["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], + check=True, + ) + + def _run_tag(self, target: str, dry_run: bool, selected: str, side_effects): + with patch("release_prepare.subprocess.run", side_effect=side_effects) as mock_run: + release_prepare._tag(target, dry_run, selected) + return mock_run + + def _side_effects(self, ddev_returncode: int = 0): + return [ + MagicMock(returncode=0), # git config user.name + MagicMock(returncode=0), # git config user.email + MagicMock(returncode=ddev_returncode), # ddev release tag + ] + + @pytest.mark.parametrize("target, dry_run, expected_flag", [ + ("prod", False, "--push"), + ("dev", False, "--no-push"), + ("prod", True, "--no-push"), + ]) + def test_push_flag(self, target, dry_run, expected_flag): + mock_run = self._run_tag(target, dry_run, "", self._side_effects(0)) + assert mock_run.mock_calls == [ + self._GIT_NAME, + self._GIT_EMAIL, + call(["ddev", "release", "tag", "all", "--skip-prerelease", expected_flag]), + ] + + def test_exit_code_3_retries_with_no_fetch(self): + side_effects = [ + MagicMock(returncode=0), # git config user.name + MagicMock(returncode=0), # git config user.email + MagicMock(returncode=3), # first ddev run → fetch failure + MagicMock(returncode=0), # retry with --no-fetch + ] + mock_run = self._run_tag("dev", False, "", side_effects) + assert mock_run.mock_calls == [ + self._GIT_NAME, + self._GIT_EMAIL, + call(["ddev", "release", "tag", "all", "--skip-prerelease", "--no-push"]), + call(["ddev", "release", "tag", "all", "--skip-prerelease", "--no-push", "--no-fetch"]), + ] + + @pytest.mark.parametrize("returncode", [0, 2]) + def test_success_exit_codes_do_not_exit(self, returncode): + self._run_tag("dev", False, "", self._side_effects(returncode)) + + def test_exit_code_1_raises_system_exit(self): + with pytest.raises(SystemExit) as exc_info: + self._run_tag("dev", False, "", self._side_effects(1)) + assert exc_info.value.code == 1 + + def test_retry_also_fails_raises_system_exit(self): + side_effects = [ + MagicMock(returncode=0), # git config user.name + MagicMock(returncode=0), # git config user.email + MagicMock(returncode=3), # first ddev run → triggers retry + MagicMock(returncode=1), # retry with --no-fetch also fails + ] + with patch("release_prepare.subprocess.run", side_effect=side_effects) as mock_run, \ + pytest.raises(SystemExit) as exc_info: + release_prepare._tag("dev", False, "") + assert exc_info.value.code == 1 + assert mock_run.mock_calls == [ + self._GIT_NAME, + self._GIT_EMAIL, + call(["ddev", "release", "tag", "all", "--skip-prerelease", "--no-push"]), + call(["ddev", "release", "tag", "all", "--skip-prerelease", "--no-push", "--no-fetch"]), + ] + + +# --------------------------------------------------------------------------- +# _detect (via main, through the detect logic) +# --------------------------------------------------------------------------- + +class TestDetect: + def test_no_packages_returns_empty(self): + with patch("release_prepare.get_all_packages", return_value=[]), \ + patch("release_prepare.resolve_packages", return_value=([], "auto-detect from tags at HEAD")): + packages, mode = release_prepare._detect("") + assert packages == [] + assert mode == "auto-detect from tags at HEAD" + + def test_packages_detected_returns_list(self): + pkgs = ["postgres", "mysql"] + with patch("release_prepare.get_all_packages", return_value=pkgs), \ + patch("release_prepare.resolve_packages", return_value=(pkgs, "auto-detect from tags at HEAD")): + packages, _ = release_prepare._detect("") + assert packages == pkgs + + def test_resolve_error_exits(self): + with patch("release_prepare.get_all_packages", return_value=[]), \ + patch("release_prepare.resolve_packages", side_effect=ValueError("bad")), \ + pytest.raises(SystemExit): + release_prepare._detect("") + + +# --------------------------------------------------------------------------- +# _validate +# --------------------------------------------------------------------------- + +class TestValidate: + def _setup(self, monkeypatch, tmp_path): + runner_temp = tmp_path / "runner_temp" + runner_temp.mkdir() + summary_file = tmp_path / "summary" + monkeypatch.setenv("RUNNER_TEMP", str(runner_temp)) + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_file)) + return runner_temp, summary_file + + def test_integrations_core_calls_ddev_validate_version(self, monkeypatch, tmp_path): + _, _ = self._setup(monkeypatch, tmp_path) + with patch("release_prepare.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \ + patch("release_prepare.validate_packages", return_value=_stable_result()): + release_prepare._validate(["postgres"], "auto", "integrations-core", "abc123", "prod", False, True) + assert mock_run.mock_calls == [call(["ddev", "validate", "version", "postgres"])] + + def test_integrations_extras_skips_ddev_validate_version(self, monkeypatch, tmp_path): + self._setup(monkeypatch, tmp_path) + with patch("release_prepare.subprocess.run") as mock_run, \ + patch("release_prepare.validate_packages", return_value=_stable_result()): + release_prepare._validate(["postgres"], "auto", "integrations-extras", "abc123", "prod", False, True) + mock_run.assert_not_called() + + def test_ddev_nonzero_exits(self, monkeypatch, tmp_path): + self._setup(monkeypatch, tmp_path) + with patch("release_prepare.subprocess.run", return_value=MagicMock(returncode=2)), \ + pytest.raises(SystemExit) as exc_info: + release_prepare._validate(["postgres"], "auto", "integrations-core", "abc123", "prod", False, True) + assert exc_info.value.code == 2 + + def test_stable_on_pre_release_branch_exits(self, monkeypatch, tmp_path): + _, summary_file = self._setup(monkeypatch, tmp_path) + with patch("release_prepare.validate_packages", return_value=_stable_result(dispatch=False)), \ + pytest.raises(SystemExit) as exc_info: + release_prepare._validate(["postgres"], "auto", "integrations-extras", "abc123", "prod", False, False) + assert exc_info.value.code == 1 + assert len(summary_file.read_text()) > 0 + + def test_package_with_fragments_exits_and_writes_summary(self, monkeypatch, tmp_path): + _, summary_file = self._setup(monkeypatch, tmp_path) + fragment_result = [{"package": "postgres", "version": "1.0.0", "type": HAS_FRAGMENTS, "dispatch": False}] + with patch("release_prepare.validate_packages", return_value=fragment_result), \ + pytest.raises(SystemExit) as exc_info: + release_prepare._validate(["postgres"], "auto", "integrations-extras", "abc123", "prod", False, True) + assert exc_info.value.code == 1 + assert len(summary_file.read_text()) > 0 + + def test_successful_validation_writes_json(self, monkeypatch, tmp_path): + runner_temp, _ = self._setup(monkeypatch, tmp_path) + with patch("release_prepare.validate_packages", return_value=_stable_result()): + release_prepare._validate(["postgres"], "auto", "integrations-extras", "abc123", "prod", False, True) + data = json.loads((runner_temp / "release_validation.json").read_text()) + assert data["mode"] == "auto" + assert data["ref"] == "abc123" + assert data["target"] == "prod" + + +# --------------------------------------------------------------------------- +# main — integration across tag + detect + validate +# --------------------------------------------------------------------------- + +class TestMain: + _GIT_NAME = call(["git", "config", "user.name", "github-actions[bot]"], check=True) + _GIT_EMAIL = call( + ["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"], + check=True, + ) + + def _setup_env(self, monkeypatch, tmp_path, extra: dict | None = None): + github_output = tmp_path / "github_output" + github_output.write_text("") + github_summary = tmp_path / "github_summary" + github_summary.write_text("") + runner_temp = tmp_path / "runner_temp" + runner_temp.mkdir() + + env = { + "TARGET": "dev", + "DRY_RUN": "false", + "SELECTED_PACKAGES": "", + "SOURCE_REPO": "integrations-extras", + "REF": "abc123", + "IS_STABLE_RELEASE": "true", + "GITHUB_OUTPUT": str(github_output), + "GITHUB_STEP_SUMMARY": str(github_summary), + "RUNNER_TEMP": str(runner_temp), + } + if extra: + env.update(extra) + for key, val in env.items(): + monkeypatch.setenv(key, val) + return github_output, github_summary, runner_temp + + def _read_outputs(self, github_output: Path) -> dict: + result = {} + for line in github_output.read_text().splitlines(): + if "=" in line: + key, _, value = line.partition("=") + result[key] = value + return result + + def _git_side_effects(self, ddev_returncode: int = 0): + return [ + MagicMock(returncode=0), # git config user.name + MagicMock(returncode=0), # git config user.email + MagicMock(returncode=ddev_returncode), # ddev release tag + ] + + def test_no_packages_writes_false_and_summary(self, monkeypatch, tmp_path): + github_output, github_summary, _ = self._setup_env(monkeypatch, tmp_path) + with patch("release_prepare.subprocess.run", side_effect=self._git_side_effects()), \ + patch("release_prepare.get_all_packages", return_value=[]), \ + patch("release_prepare.resolve_packages", return_value=([], "auto-detect from tags at HEAD")): + release_prepare.main() + outputs = self._read_outputs(github_output) + assert outputs["has_packages"] == "false" + assert "No packages detected" in github_summary.read_text() + + def test_packages_detected_writes_outputs(self, monkeypatch, tmp_path): + github_output, _, _ = self._setup_env(monkeypatch, tmp_path) + pkgs = ["postgres"] + with patch("release_prepare.subprocess.run", side_effect=self._git_side_effects()), \ + patch("release_prepare.get_all_packages", return_value=pkgs), \ + patch("release_prepare.resolve_packages", return_value=(pkgs, "auto-detect from tags at HEAD")), \ + patch("release_prepare.validate_packages", return_value=_stable_result()): + release_prepare.main() + outputs = self._read_outputs(github_output) + assert outputs["has_packages"] == "true" + assert json.loads(outputs["packages"]) == pkgs + + def test_tag_failure_exits_before_detect(self, monkeypatch, tmp_path): + self._setup_env(monkeypatch, tmp_path) + side_effects = [ + MagicMock(returncode=0), # git config user.name + MagicMock(returncode=0), # git config user.email + MagicMock(returncode=1), # ddev release tag fails + ] + with patch("release_prepare.subprocess.run", side_effect=side_effects), \ + patch("release_prepare.get_all_packages") as mock_detect, \ + pytest.raises(SystemExit): + release_prepare.main() + mock_detect.assert_not_called() + + def test_dry_run_uses_no_push_flag(self, monkeypatch, tmp_path): + self._setup_env(monkeypatch, tmp_path, extra={"DRY_RUN": "true", "TARGET": "prod"}) + pkgs = ["postgres"] + with patch("release_prepare.subprocess.run", side_effect=self._git_side_effects()) as mock_run, \ + patch("release_prepare.get_all_packages", return_value=pkgs), \ + patch("release_prepare.resolve_packages", return_value=(pkgs, "auto-detect from tags at HEAD")), \ + patch("release_prepare.validate_packages", return_value=_stable_result()): + release_prepare.main() + ddev_call = mock_run.mock_calls[2] + assert "--no-push" in ddev_call.args[0] + + def test_validate_failure_exits_from_main(self, monkeypatch, tmp_path): + self._setup_env(monkeypatch, tmp_path) + pkgs = ["postgres"] + fragment_result = [{"package": "postgres", "version": "1.0.0", "type": HAS_FRAGMENTS, "dispatch": False}] + with patch("release_prepare.subprocess.run", side_effect=self._git_side_effects()), \ + patch("release_prepare.get_all_packages", return_value=pkgs), \ + patch("release_prepare.resolve_packages", return_value=(pkgs, "auto-detect from tags at HEAD")), \ + patch("release_prepare.validate_packages", return_value=fragment_result), \ + pytest.raises(SystemExit) as exc_info: + release_prepare.main() + assert exc_info.value.code == 1 diff --git a/.github/workflows/scripts/tests/test_validation.py b/.github/workflows/scripts/tests/test_validation.py new file mode 100644 index 0000000000000..0deac2b32926d --- /dev/null +++ b/.github/workflows/scripts/tests/test_validation.py @@ -0,0 +1,153 @@ +"""Tests for _release.validation.""" +from pathlib import Path + +import pytest + +from _release.validation import ( + HAS_FRAGMENTS, + NO_VERSION, + PRE_RELEASE, + STABLE, + UNRELEASED, + get_version, + has_changelog_fragments, + is_pre_release, + validate_package, + validate_packages, +) + + +def _make_package(root: Path, name: str, version: str) -> Path: + """Create a minimal package directory under *root*.""" + about = root / name / "datadog_checks" / name / "__about__.py" + about.parent.mkdir(parents=True) + about.write_text(f'__version__ = "{version}"\n') + return root / name + + + +class TestGetVersion: + def test_reads_version(self, tmp_path): + _make_package(tmp_path, "postgres", "1.2.3") + assert get_version("postgres", tmp_path) == "1.2.3" + + def test_missing_about_returns_none(self, tmp_path): + (tmp_path / "postgres").mkdir() + assert get_version("postgres", tmp_path) is None + + def test_version_with_pre_release(self, tmp_path): + _make_package(tmp_path, "pkg", "2.0.0rc1") + assert get_version("pkg", tmp_path) == "2.0.0rc1" + + +class TestIsPreRelease: + @pytest.mark.parametrize("version", ["1.0.0a1", "2.0.0b3", "3.0.0rc2"]) + def test_pre_release_versions(self, version): + assert is_pre_release(version) is True + + @pytest.mark.parametrize("version", ["1.0.0", "2.3.4", "10.0.1"]) + def test_stable_versions(self, version): + assert is_pre_release(version) is False + + +class TestHasChangelogFragments: + def test_no_changelog_dir(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + assert has_changelog_fragments("pkg", tmp_path) is False + + def test_empty_changelog_dir(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + (tmp_path / "pkg" / "changelog.d").mkdir() + assert has_changelog_fragments("pkg", tmp_path) is False + + def test_gitkeep_is_ignored(self, tmp_path): + """Bug fix: .gitkeep must NOT be counted as a changelog fragment.""" + _make_package(tmp_path, "pkg", "1.0.0") + changelog_d = tmp_path / "pkg" / "changelog.d" + changelog_d.mkdir() + (changelog_d / ".gitkeep").write_text("") + assert has_changelog_fragments("pkg", tmp_path) is False + + def test_readme_is_ignored(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + changelog_d = tmp_path / "pkg" / "changelog.d" + changelog_d.mkdir() + (changelog_d / "README.md").write_text("# Changelog fragments\n") + assert has_changelog_fragments("pkg", tmp_path) is False + + def test_valid_fragment_detected(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + changelog_d = tmp_path / "pkg" / "changelog.d" + changelog_d.mkdir() + (changelog_d / "1234.fixed").write_text("Fixed a bug.") + assert has_changelog_fragments("pkg", tmp_path) is True + + +class TestValidatePackage: + def test_stable(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + result = validate_package("pkg", tmp_path) + assert result == {"package": "pkg", "version": "1.0.0", "type": STABLE, "dispatch": True} + + def test_no_version(self, tmp_path): + (tmp_path / "pkg").mkdir() + result = validate_package("pkg", tmp_path) + assert result["type"] == NO_VERSION + assert result["dispatch"] is False + assert result["version"] is None + + def test_unreleased(self, tmp_path): + _make_package(tmp_path, "pkg", "0.0.1") + result = validate_package("pkg", tmp_path) + assert result["type"] == UNRELEASED + assert result["dispatch"] is False + + def test_pre_release_on_pre_release_branch(self, tmp_path): + _make_package(tmp_path, "pkg", "2.0.0b1") + result = validate_package("pkg", tmp_path, is_stable_release=False) + assert result["type"] == PRE_RELEASE + assert result["dispatch"] is True + + def test_pre_release_on_stable_branch(self, tmp_path): + _make_package(tmp_path, "pkg", "2.0.0b1") + result = validate_package("pkg", tmp_path, is_stable_release=True) + assert result["type"] == PRE_RELEASE + assert result["dispatch"] is False + + def test_has_fragments(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + changelog_d = tmp_path / "pkg" / "changelog.d" + changelog_d.mkdir() + (changelog_d / "7.fixed").write_text("Fix.") + result = validate_package("pkg", tmp_path) + assert result["type"] == HAS_FRAGMENTS + assert result["dispatch"] is False + + def test_pre_release_dispatches_despite_fragments(self, tmp_path): + """Pre-release packages are dispatched even if they have fragments.""" + _make_package(tmp_path, "pkg", "1.0.0a1") + changelog_d = tmp_path / "pkg" / "changelog.d" + changelog_d.mkdir() + (changelog_d / "1.fixed").write_text("Fix.") + result = validate_package("pkg", tmp_path, is_stable_release=False) + assert result["type"] == PRE_RELEASE + assert result["dispatch"] is True + + def test_stable_on_pre_release_branch(self, tmp_path): + _make_package(tmp_path, "pkg", "1.0.0") + result = validate_package("pkg", tmp_path, is_stable_release=False) + assert result["type"] == STABLE + assert result["dispatch"] is False + + +class TestValidatePackages: + def test_multiple_packages(self, tmp_path): + _make_package(tmp_path, "a", "1.0.0") + _make_package(tmp_path, "b", "2.0.0b1") + results = validate_packages(["a", "b"], tmp_path, is_stable_release=False) + assert len(results) == 2 + by_pkg = {r["package"]: r for r in results} + assert by_pkg["a"]["type"] == STABLE + assert by_pkg["a"]["dispatch"] is False # stable package not dispatched on pre-release branch + assert by_pkg["b"]["type"] == PRE_RELEASE + assert by_pkg["b"]["dispatch"] is True # pre-release branch context diff --git a/.github/workflows/test-release-scripts.yml b/.github/workflows/test-release-scripts.yml new file mode 100644 index 0000000000000..6cf8655993435 --- /dev/null +++ b/.github/workflows/test-release-scripts.yml @@ -0,0 +1,34 @@ +name: Test release pipeline scripts + +on: + push: + branches: + - master + paths: + - ".github/workflows/scripts/**" + - ".github/workflows/release-dispatch.yml" + - ".github/workflows/release-trigger.yml" + pull_request: + paths: + - ".github/workflows/scripts/**" + - ".github/workflows/release-dispatch.yml" + - ".github/workflows/release-trigger.yml" + +jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Install pytest + run: pip install pytest==8.3.5 + + - name: Run tests + working-directory: .github/workflows/scripts + run: pytest tests/ -v