diff --git a/.github/workflows/test-ci-scripts.yml b/.github/workflows/test-ci-scripts.yml new file mode 100644 index 00000000..95df8c2a --- /dev/null +++ b/.github/workflows/test-ci-scripts.yml @@ -0,0 +1,26 @@ +name: CI scripts + +on: + push: + branches: [main] + paths: + - 'tools/**' + - '.github/workflows/test-ci-scripts.yml' + pull_request: + branches: [main] + paths: + - 'tools/**' + - '.github/workflows/test-ci-scripts.yml' + +permissions: + contents: read + +jobs: + test-bump-datadog-agent: + name: Test bump_datadog_agent + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run unit tests + run: python3 -m unittest discover -s tools/bump_datadog_agent/tests -v diff --git a/.gitignore b/.gitignore index fe006405..d58c8e31 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ .DS_Store +__pycache__/ + # Fuzz corpus: keep checked in for regression testing. # Uncomment the line below if corpus grows too large: # interp/builtins/tests/*/testdata/fuzz/*/corpus-* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..0415a1e7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +--- +stages: + - trigger_release + +.dd_octo_sts: + id_tokens: + DDOCTOSTS_ID_TOKEN: + aud: dd-octo-sts + +bump_datadog_agent: + stage: trigger_release + image: registry.ddbuild.io/ci/datadog-agent-buildimages/linux:latest + tags: ["arch:arm64"] + extends: .dd_octo_sts + resource_group: rshell-bump + rules: + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - if: $CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH == "main" && $BUMP_VERSION =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ + before_script: + # Install dependencies BEFORE minting the GITHUB_TOKEN so a compromised + # package's install-time hook can't read the token from the environment. + # Pin to a vetted PyGithub version; review periodically and bump deliberately. + - pip install "PyGithub==2.5.0" + - export GITHUB_TOKEN=$(dd-octo-sts token --scope DataDog/datadog-agent --policy self.rshell.bump-rshell-version) + script: + - python3 tools/bump_datadog_agent/bump.py "${CI_COMMIT_TAG:-$BUMP_VERSION}" diff --git a/tools/bump_datadog_agent/bump.py b/tools/bump_datadog_agent/bump.py new file mode 100755 index 00000000..e6a969ed --- /dev/null +++ b/tools/bump_datadog_agent/bump.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Open a PR on DataDog/datadog-agent that bumps the pinned rshell version. + +Invoked by the `bump_datadog_agent` GitLab CI job after a new rshell tag is +detected. Expects: + - sys.argv[1]: the rshell tag (e.g. "v0.0.11") + - env GITHUB_TOKEN: a short-lived dd-octo-sts token scoped to + DataDog/datadog-agent with contents:write + pull-requests:write. +""" + +from __future__ import annotations + +import hashlib +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +TARGET_REPO = "DataDog/datadog-agent" +TARGET_BASE = "main" +RSHELL_MODULE = "github.com/DataDog/rshell" +REVIEW_TEAM = "action-platform" +PR_LABELS = ["changelog/no-changelog", "ask-review"] +GIT_USER_NAME = "github-actions[bot]" +GIT_USER_EMAIL = "github-actions[bot]@users.noreply.github.com" + + +def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=cwd, check=check, text=True, capture_output=True) + + +def log(msg: str) -> None: + """Emit a progress line to stdout. Call sites must never pass secrets.""" + print(f"[bump] {msg}", flush=True) + + +def configure_credentials(workdir: Path, token: str) -> Path: + """Store the GitHub token in a local git credentials file under .git/. + + Keeps the token out of process argv (visible to `ps`), command-line logs, + and subprocess exception tracebacks. The file is mode 0600 and lives inside + the ephemeral clone directory, which is discarded when the runner exits. + """ + creds_path = workdir / ".git" / "ci-credentials" + creds_path.write_text(f"https://x-access-token:{token}@github.com\n") + creds_path.chmod(0o600) + run(["git", "config", "credential.helper", f"store --file={creds_path}"], cwd=workdir) + return creds_path + + +_RSHELL_REPLACE_RE = re.compile( + rf"^[ \t]*(?:replace\s+)?{re.escape(RSHELL_MODULE)}(?:\s+v\S+)?\s+=>\s+[^\n]*$\n?", + re.MULTILINE, +) + + +def strip_rshell_replace(go_mod: Path) -> None: + """Remove rshell replace directives from go.mod in every valid Go form. + + Handles: + - single-line unversioned: replace github.com/DataDog/rshell => /path + - single-line versioned: replace github.com/DataDog/rshell v0.0.10 => /path + - block-form entries (no leading `replace` keyword on the line itself) + + Any leftover empty `replace ( )` block is normalized away by `dda inv tidy` + downstream. + """ + original = go_mod.read_text() + updated = _RSHELL_REPLACE_RE.sub("", original) + if updated != original: + go_mod.write_text(updated) + log(f"stripped replace directive(s) for {RSHELL_MODULE} from go.mod") + + +def current_rshell_version(go_mod: Path) -> str | None: + """Return the rshell version pinned in a `require` declaration, ignoring `replace` lines.""" + pattern = re.compile( + rf"^\s*(?:require\s+)?{re.escape(RSHELL_MODULE)}\s+(v\S+)(?!\s*=>)", + re.MULTILINE, + ) + m = pattern.search(go_mod.read_text()) + return m.group(1) if m else None + + +def write_release_note(repo_root: Path, version: str) -> Path: + # Deterministic per (module, version) so retries produce the identical file. + suffix = hashlib.sha256(f"{RSHELL_MODULE}@{version}".encode()).hexdigest()[:16] + note = repo_root / "releasenotes" / "notes" / f"bump-rshell-{version}-{suffix}.yaml" + note.write_text( + "---\n" + "enhancements:\n" + " - |\n" + f" Bump ``rshell`` to {version} for the Private Action Runner.\n" + ) + return note + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: bump.py ", file=sys.stderr) + return 2 + version = sys.argv[1] + if not re.fullmatch(r"v\d+\.\d+\.\d+", version): + print(f"invalid version {version!r}; expected vX.Y.Z", file=sys.stderr) + return 2 + + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN is not set; dd-octo-sts exchange failed upstream", file=sys.stderr) + return 1 + + log(f"preparing bump of {RSHELL_MODULE} to {version}") + from github import Auth, Github, GithubException + + gh = Github(auth=Auth.Token(token), per_page=100) + repo = gh.get_repo(TARGET_REPO) + branch = f"bump-rshell-{version}" + + # Scrub the token from the process environment now that PyGithub has + # internalized it. Subsequent subprocess calls (git, go, dda inv tidy, + # which executes code from the freshly cloned repo) inherit os.environ by + # default; leaving the write-scoped GITHUB_TOKEN in that environment would + # let any of them exfiltrate it. PyGithub still holds the token in its + # auth object, and git push authenticates via the on-disk credential-helper + # file rather than the env var. + os.environ.pop("GITHUB_TOKEN", None) + + # Include closed + merged PRs too, so we can distinguish: + # - open: stop (human owns the cycle) + # - merged: log and let the go.mod/go.sum diff decide + # - closed unmerged: reopen it at create-pull time instead of failing + log(f"checking {TARGET_REPO} for existing PRs with head={branch}") + all_existing = list( + repo.get_pulls(state="all", head=f"{TARGET_REPO.split('/')[0]}:{branch}") + ) + open_existing = [p for p in all_existing if p.state == "open"] + if open_existing: + log(f"open PR already exists: {open_existing[0].html_url}; nothing to do") + return 0 + + closed_unmerged = [p for p in all_existing if p.state == "closed" and not p.merged] + merged_existing = [p for p in all_existing if p.merged] + if merged_existing: + log( + f"prior PR merged ({merged_existing[0].html_url}); will no-op unless " + "go.mod/go.sum shows a genuine diff" + ) + + # Use a fresh, unique tempdir each run. Auto-cleanup on exit means no stale + # state leaks between runs; starting empty means `git clone` never fails + # with exit 128 because a prior directory already existed. + with tempfile.TemporaryDirectory(prefix="bump-datadog-agent-") as td: + workdir = Path(td) / "datadog-agent" + clone_url = f"https://github.com/{TARGET_REPO}.git" + log(f"cloning {TARGET_REPO}@{TARGET_BASE} into {workdir}") + run(["git", "clone", "--depth=1", "--branch", TARGET_BASE, clone_url, str(workdir)]) + log("configuring git credentials (token stored in .git/ci-credentials, not argv)") + configure_credentials(workdir, token) + run(["git", "config", "user.name", GIT_USER_NAME], cwd=workdir) + run(["git", "config", "user.email", GIT_USER_EMAIL], cwd=workdir) + log(f"creating branch {branch}") + run(["git", "checkout", "-b", branch], cwd=workdir) + + go_mod = workdir / "go.mod" + previous_version = current_rshell_version(go_mod) + log(f"current pinned version in go.mod: {previous_version or ''}") + + if previous_version == version: + log(f"datadog-agent already pins rshell at {version}; nothing to do") + return 0 + + strip_rshell_replace(go_mod) + log(f"running: go get {RSHELL_MODULE}@{version}") + run(["go", "get", f"{RSHELL_MODULE}@{version}"], cwd=workdir) + log("running: dda inv tidy") + run(["dda", "inv", "tidy"], cwd=workdir) + + run(["git", "add", "-A"], cwd=workdir) + diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=workdir) + if diff.returncode == 0: + log(f"no changes to go.mod/go.sum; datadog-agent already at rshell {version}") + return 0 + + note = write_release_note(workdir, version) + log(f"wrote release note: {note.relative_to(workdir)}") + run(["git", "add", str(note)], cwd=workdir) + + commit_msg = ( + f"Bump rshell dependency from {previous_version} to {version}" + if previous_version + else f"Bump rshell dependency to {version}" + ) + log(f"committing: {commit_msg}") + run(["git", "commit", "-m", commit_msg], cwd=workdir) + log(f"pushing branch {branch} to origin (force)") + # Force push is safe: this branch is only ever written by this script, and + # the force handles retries after a prior failure (deterministic tree, + # non-deterministic commit timestamp). + run(["git", "push", "--force", "origin", branch], cwd=workdir) + + pr_title = f"[automated] Bump rshell to {version}" + pr_body = ( + f"Automated bump of `{RSHELL_MODULE}` to " + f"[{version}](https://github.com/DataDog/rshell/releases/tag/{version}).\n" + ) + if closed_unmerged: + # Reusing a previously-closed PR avoids GitHub's duplicate-PR error and + # keeps the review history in one thread. + pr = closed_unmerged[0] + log(f"reopening prior closed PR: {pr.html_url}") + pr.edit(state="open", title=pr_title, body=pr_body) + else: + log("opening draft PR") + pr = repo.create_pull( + title=pr_title, + body=pr_body, + base=TARGET_BASE, + head=branch, + draft=True, + ) + log(f"opened draft PR: {pr.html_url}") + + try: + pr.add_to_labels(*PR_LABELS) + log(f"added labels: {', '.join(PR_LABELS)}") + except GithubException as e: + log(f"warning: failed to add labels {PR_LABELS}: {e}") + + try: + pr.create_review_request(team_reviewers=[REVIEW_TEAM]) + log(f"requested review from @DataDog/{REVIEW_TEAM}") + except GithubException as e: + log(f"warning: failed to request review from @DataDog/{REVIEW_TEAM}: {e}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/bump_datadog_agent/tests/test_bump.py b/tools/bump_datadog_agent/tests/test_bump.py new file mode 100644 index 00000000..ab196b01 --- /dev/null +++ b/tools/bump_datadog_agent/tests/test_bump.py @@ -0,0 +1,366 @@ +"""Tests for tools/bump_datadog_agent/bump.py. + +Runs with stdlib only; PyGithub is stubbed before the script is imported, so +the suite executes anywhere Python 3.10+ is installed. From the repo root: + + python3 -m unittest discover -s tools/bump_datadog_agent/tests -v +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +_github_stub = MagicMock() +_github_stub.GithubException = type("GithubException", (Exception,), {}) +sys.modules["github"] = _github_stub + +# Put the script's directory on sys.path so we can `import bump` regardless of +# where the test runner is invoked from. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import bump # noqa: E402 + + +class TestConfigureCredentials(unittest.TestCase): + def test_writes_credentials_file_and_invokes_git_config(self): + with tempfile.TemporaryDirectory() as td: + workdir = Path(td) + (workdir / ".git").mkdir() + with patch("bump.subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + path = bump.configure_credentials(workdir, "ghs_SECRET123") + + self.assertEqual(path, workdir / ".git" / "ci-credentials") + self.assertTrue(path.exists()) + self.assertIn("ghs_SECRET123", path.read_text()) + self.assertIn("x-access-token", path.read_text()) + # file permissions are 0o600 + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + # git config was invoked with the path, not the token + self.assertEqual(mock_run.call_count, 1) + called_cmd = mock_run.call_args.args[0] + self.assertEqual(called_cmd[:3], ["git", "config", "credential.helper"]) + self.assertNotIn("ghs_SECRET123", " ".join(called_cmd)) + self.assertIn(str(path), called_cmd[3]) + + +class TestStripRshellReplace(unittest.TestCase): + def test_removes_replace_directive(self): + original = textwrap.dedent( + """\ + module m + go 1.25 + + replace github.com/DataDog/rshell => /local/path + + require ( + github.com/DataDog/rshell v0.0.10 + ) + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("replace github.com/DataDog/rshell", updated) + self.assertIn("require (", updated) + self.assertIn("github.com/DataDog/rshell v0.0.10", updated) + + def test_noop_when_no_replace(self): + original = "module m\ngo 1.25\n\nrequire github.com/DataDog/rshell v0.0.10\n" + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + self.assertEqual(go_mod.read_text(), original) + + def test_preserves_other_replaces(self): + original = textwrap.dedent( + """\ + module m + go 1.25 + + replace github.com/other/mod => /a + replace github.com/DataDog/rshell => /local/path + replace github.com/yet/another => /b + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("DataDog/rshell", updated) + self.assertIn("github.com/other/mod", updated) + self.assertIn("github.com/yet/another", updated) + + def test_removes_single_line_versioned_replace(self): + original = textwrap.dedent( + """\ + module m + go 1.25 + + replace github.com/DataDog/rshell v0.0.10 => /local/path + + require github.com/DataDog/rshell v0.0.10 + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("replace github.com/DataDog/rshell", updated) + self.assertIn("require github.com/DataDog/rshell v0.0.10", updated) + + def test_removes_block_form_unversioned_entry(self): + original = textwrap.dedent( + """\ + module m + go 1.25 + + replace ( + github.com/DataDog/rshell => /local/path + ) + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("DataDog/rshell", updated) + + def test_removes_block_form_versioned_entry(self): + original = textwrap.dedent( + """\ + module m + go 1.25 + + replace ( + github.com/DataDog/rshell v0.0.10 => /local/path + ) + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("DataDog/rshell", updated) + + def test_strips_only_rshell_entries_from_mixed_block(self): + original = textwrap.dedent( + """\ + replace ( + github.com/other => /a + github.com/DataDog/rshell => /rshell-local + github.com/DataDog/rshell v0.0.10 => /rshell-pinned + github.com/another => /b + ) + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + updated = go_mod.read_text() + self.assertNotIn("DataDog/rshell", updated) + self.assertIn("github.com/other => /a", updated) + self.assertIn("github.com/another => /b", updated) + + def test_does_not_touch_require_lines(self): + # Lines in a require block look similar but have no `=>`; must not be + # stripped. + original = textwrap.dedent( + """\ + require ( + github.com/DataDog/rshell v0.0.10 + ) + """ + ) + with tempfile.TemporaryDirectory() as td: + go_mod = Path(td) / "go.mod" + go_mod.write_text(original) + bump.strip_rshell_replace(go_mod) + self.assertEqual(go_mod.read_text(), original) + + +class TestCurrentRshellVersion(unittest.TestCase): + def _write(self, content: str) -> Path: + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + go_mod = Path(td.name) / "go.mod" + go_mod.write_text(content) + return go_mod + + def test_finds_version_in_require_block(self): + go_mod = self._write( + textwrap.dedent( + """\ + require ( + github.com/DataDog/rshell v0.0.10 + github.com/DataDog/other v1.2.3 + ) + """ + ) + ) + self.assertEqual(bump.current_rshell_version(go_mod), "v0.0.10") + + def test_finds_version_in_single_require(self): + go_mod = self._write("require github.com/DataDog/rshell v0.0.11\n") + self.assertEqual(bump.current_rshell_version(go_mod), "v0.0.11") + + def test_ignores_replace_line_version(self): + go_mod = self._write( + textwrap.dedent( + """\ + require github.com/DataDog/rshell v0.0.10 + replace github.com/DataDog/rshell v0.0.10 => /local + """ + ) + ) + self.assertEqual(bump.current_rshell_version(go_mod), "v0.0.10") + + def test_returns_none_when_absent(self): + go_mod = self._write("module m\ngo 1.25\n") + self.assertIsNone(bump.current_rshell_version(go_mod)) + + +class TestWriteReleaseNote(unittest.TestCase): + def test_writes_correct_format_and_path(self): + with tempfile.TemporaryDirectory() as td: + repo_root = Path(td) + (repo_root / "releasenotes" / "notes").mkdir(parents=True) + note = bump.write_release_note(repo_root, "v0.0.11") + self.assertTrue(note.exists()) + self.assertEqual(note.parent, repo_root / "releasenotes" / "notes") + self.assertTrue(note.name.startswith("bump-rshell-v0.0.11-")) + self.assertTrue(note.name.endswith(".yaml")) + content = note.read_text() + self.assertIn("enhancements:", content) + self.assertIn("Bump ``rshell`` to v0.0.11", content) + + def test_filename_is_deterministic_for_same_version(self): + with tempfile.TemporaryDirectory() as td: + repo_root = Path(td) + (repo_root / "releasenotes" / "notes").mkdir(parents=True) + a = bump.write_release_note(repo_root, "v0.0.11") + b = bump.write_release_note(repo_root, "v0.0.11") + self.assertEqual(a.name, b.name) + + def test_filename_differs_between_versions(self): + with tempfile.TemporaryDirectory() as td: + repo_root = Path(td) + (repo_root / "releasenotes" / "notes").mkdir(parents=True) + a = bump.write_release_note(repo_root, "v0.0.11") + b = bump.write_release_note(repo_root, "v0.0.12") + self.assertNotEqual(a.name, b.name) + + +class TestMainInputValidation(unittest.TestCase): + def test_missing_argv_returns_2(self): + with patch.object(sys, "argv", ["bump_datadog_agent.py"]): + self.assertEqual(bump.main(), 2) + + def test_extra_argv_returns_2(self): + with patch.object(sys, "argv", ["bump_datadog_agent.py", "v0.0.11", "extra"]): + self.assertEqual(bump.main(), 2) + + def test_rejects_version_without_v_prefix(self): + with patch.object(sys, "argv", ["bump_datadog_agent.py", "0.0.11"]): + self.assertEqual(bump.main(), 2) + + def test_rejects_non_semver(self): + for bad in ("v0.0", "v1.2.3-alpha", "vX.Y.Z", "v1.2.3.4"): + with self.subTest(bad=bad): + with patch.object(sys, "argv", ["bump_datadog_agent.py", bad]): + self.assertEqual(bump.main(), 2) + + def test_missing_github_token_returns_1(self): + with patch.dict(os.environ, {}, clear=True): + with patch.object(sys, "argv", ["bump_datadog_agent.py", "v0.0.11"]): + self.assertEqual(bump.main(), 1) + + +class TestMainIdempotency(unittest.TestCase): + def _setup_pulls(self, prs): + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = prs + _github_stub.Github.reset_mock() + _github_stub.Github.return_value.get_repo.return_value = mock_repo + return mock_repo + + def test_exits_zero_when_open_pr_already_exists(self): + existing_pr = MagicMock() + existing_pr.state = "open" + existing_pr.merged = False + existing_pr.html_url = "https://github.com/DataDog/datadog-agent/pull/999" + mock_repo = self._setup_pulls([existing_pr]) + + with patch.dict(os.environ, {"GITHUB_TOKEN": "fake-token"}): + with patch.object(sys, "argv", ["bump.py", "v0.0.99"]): + with patch("bump.subprocess.run") as mock_run: + result = bump.main() + mock_run.assert_not_called() + + self.assertEqual(result, 0) + call_kwargs = mock_repo.get_pulls.call_args.kwargs + # Must query *all* PRs (not just open) so we can detect closed/merged + # ones and handle them without a duplicate-PR error. + self.assertEqual(call_kwargs["state"], "all") + self.assertEqual(call_kwargs["head"], "DataDog:bump-rshell-v0.0.99") + + def test_does_not_short_circuit_on_closed_unmerged_pr(self): + # Closed-but-not-merged PR should NOT stop the flow — the early exit is + # only for open PRs. We prove this by letting the subprocess calls + # run; the first one (git clone) fails in the test env, which means + # we've exited early only if subprocess was *never* called. + closed_pr = MagicMock(state="closed", merged=False) + self._setup_pulls([closed_pr]) + + with patch.dict(os.environ, {"GITHUB_TOKEN": "fake-token"}): + with patch.object(sys, "argv", ["bump.py", "v0.0.99"]): + with patch("bump.run") as mock_run: + # Have the helper raise on first call so we don't actually + # hit the network / disk. + mock_run.side_effect = RuntimeError("stop here") + with self.assertRaises(RuntimeError): + bump.main() + self.assertTrue( + mock_run.called, + "bump should have proceeded past the early-exit check", + ) + + +class TestTokenScrubbing(unittest.TestCase): + def test_github_token_removed_from_environ_before_subprocess_calls(self): + # Use the "open PR already exists" path because it goes just far + # enough to create the GitHub client — which is where the token should + # get scrubbed — without needing to mock clone/go/dda. + existing_pr = MagicMock( + state="open", + merged=False, + html_url="https://github.com/DataDog/datadog-agent/pull/1", + ) + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = [existing_pr] + _github_stub.Github.reset_mock() + _github_stub.Github.return_value.get_repo.return_value = mock_repo + + with patch.dict(os.environ, {"GITHUB_TOKEN": "fake-token"}, clear=False): + with patch.object(sys, "argv", ["bump.py", "v0.0.99"]): + bump.main() + # After main(), the token must no longer be readable from env. + self.assertNotIn("GITHUB_TOKEN", os.environ) + + +if __name__ == "__main__": + unittest.main()