-
Notifications
You must be signed in to change notification settings - Fork 1
ci: add GitLab pipeline to bump rshell in datadog-agent on new tag #188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
matt-dz
wants to merge
24
commits into
main
Choose a base branch
from
matt-dz/gitlab-bump-datadog-agent
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
4b8bddb
ci: add GitLab pipeline to bump rshell in datadog-agent on new tag
matt-dz 513701b
ci: allow manual web trigger with BUMP_VERSION variable
matt-dz b1ad597
ci(bump): drop bot/ prefix on bump branch
matt-dz 2acb6bf
ci(bump): clone datadog-agent anonymously; only auth on push
matt-dz e7ee0eb
test(bump): add stdlib unit tests for bump_datadog_agent.py
matt-dz a5d0181
ci(bump): redact credentials in logged command lines
matt-dz d63092d
ci(bump): stop logging commands; move token to git credentials file
matt-dz 26f0d98
ci(bump): add progress logging that never touches credentials
matt-dz 9e0ed9f
ci(bump): drop PyGithub; use stdlib urllib for GitHub REST calls
matt-dz bd57580
Revert "ci(bump): drop PyGithub; use stdlib urllib for GitHub REST ca…
matt-dz 20d3ba3
ci(bump): install PyGithub in before_script
matt-dz 9cf0d68
ci(bump): restrict web-triggered bump to protected main branch
matt-dz 1b0b333
ci(bump): drop redundant ref_protected check (main is always protected)
matt-dz 0633905
ci(bump): skip bump flow when target version is already pinned
matt-dz 3c0ac38
ci(bump): make release-note filename deterministic per version
matt-dz 5ecc8eb
ci(bump): force-push bot branch to enable retries after partial failure
matt-dz 5f4aebd
ci(bump): install deps before minting GitHub token
matt-dz b363e85
ci(bump): strip rshell replace directives in all valid go.mod forms
matt-dz b15a36f
refactor: move bump script to tools/ and add GitHub Actions test work…
matt-dz 9ae59ee
ci(bump): pin PyGithub to a specific version for reproducibility
matt-dz 47b1847
ci(bump): use unique tempdir per run instead of hard-coded /tmp path
matt-dz 810ac25
ci(bump): inline token export instead of !reference template
matt-dz ef39f15
ci(bump): scrub GITHUB_TOKEN from env before spawning subprocesses
matt-dz 8537f5e
ci(bump): handle closed/merged PRs; reopen instead of duplicate-creating
matt-dz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
matt-dz marked this conversation as resolved.
|
||
|
|
||
|
|
||
| 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 <tag>", 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 '<none>'}") | ||
|
|
||
| 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()) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@matt-dz
Q: Don't we already have existing tooling that will do this?
Example PRs like this: #198
By "🤖 Generated by DataDog Automated Dependency Management System"