Skip to content

Commit 33d616c

Browse files
committed
feat(buildkite): derive GitHub comment context natively
`--scm github` read its configuration solely from GITHUB_* variables, so Buildkite users had to shim every one of them to get PR comments. Fall back to Buildkite's own variables when the GITHUB_* equivalents are absent: PR number, commit, branch, checkout path, commit message, build creator, and owner/repository parsed from BUILDKITE_REPO (preferring the pipeline repository over a contributor's fork). Explicit GITHUB_* and PR_NUMBER values still take priority, and GitHub Enterprise remains configurable via GITHUB_API_URL. A running Buildkite PR build maps to the supported `synchronize` comment path, and a non-PR build maps to `push`, so event routing is unchanged. Default-branch detection requires an actual branch name rather than treating two unset variables as a match, which would otherwise mark any build as the default branch and overwrite the repository baseline. Ref: CE-379
1 parent 04db555 commit 33d616c

3 files changed

Lines changed: 273 additions & 12 deletions

File tree

docs/ci-cd.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ steps:
8181
SOCKET_SECURITY_API_TOKEN: "${SOCKET_SECURITY_API_TOKEN}"
8282
```
8383
84+
The CLI reads Buildkite's native `BUILDKITE_COMMIT`, `BUILDKITE_BRANCH`,
85+
`BUILDKITE_PULL_REQUEST`, and `BUILDKITE_PULL_REQUEST_BASE_BRANCH` variables.
86+
For pull-request builds, ensure the checkout contains the base branch and the
87+
checked-out head commit. The CLI uses those local refs first and performs a
88+
targeted fetch only when a required ref or its comparison history is missing;
89+
it does not fetch every remote ref and tag during startup.
90+
91+
When `--scm github` is used from Buildkite, the CLI also derives GitHub comment
92+
context from `BUILDKITE_REPO`, `BUILDKITE_BUILD_CHECKOUT_PATH`, and the variables
93+
above. Set `GH_API_TOKEN` to a GitHub token with the required repository access.
94+
GitHub Enterprise users should also set `GITHUB_API_URL`; GitHub.com defaults to
95+
`https://api.github.com`.
96+
8497
#### Merge-base baselines in Buildkite (dynamic pipelines)
8598

8699
Notes for using `--base-commit-sha` (see the

socketsecurity/core/scm/github.py

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import os
33
import sys
4+
import urllib.parse
45
from dataclasses import dataclass
56

67
from git import Optional
@@ -34,6 +35,31 @@ class GithubConfig:
3435
event_action: Optional[str]
3536
headers: dict
3637

38+
@staticmethod
39+
def _repository_from_buildkite() -> tuple[str, str]:
40+
"""Return ``(owner, repository)`` from Buildkite's Git repository URL."""
41+
repository_url = (
42+
# Comments and statuses belong to the pipeline/base repository,
43+
# not a contributor's fork from BUILDKITE_PULL_REQUEST_REPO.
44+
os.getenv("BUILDKITE_REPO")
45+
or os.getenv("BUILDKITE_PULL_REQUEST_REPO")
46+
or ""
47+
).strip()
48+
if not repository_url:
49+
return "", ""
50+
51+
if "://" in repository_url:
52+
repository_path = urllib.parse.urlparse(repository_url).path
53+
elif ":" in repository_url:
54+
# SCP-style SSH URL: git@github.com:owner/repository.git
55+
repository_path = repository_url.split(":", 1)[1]
56+
else:
57+
repository_path = repository_url
58+
parts = repository_path.strip("/").removesuffix(".git").split("/")
59+
if len(parts) < 2:
60+
return "", ""
61+
return parts[-2], parts[-1]
62+
3763
@classmethod
3864
def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig':
3965
"""Create config from environment variables with optional overrides"""
@@ -42,42 +68,91 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig':
4268
log.error("Unable to get Github API Token from GH_API_TOKEN")
4369
sys.exit(2)
4470

45-
# Use provided PR number if available, otherwise fall back to env var
71+
is_buildkite = os.getenv("BUILDKITE") == "true"
72+
buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST")
73+
is_buildkite_pr = bool(
74+
is_buildkite
75+
and buildkite_pr
76+
and buildkite_pr.casefold() != "false"
77+
)
78+
79+
# Use explicit/GitHub-compatible values first, then native Buildkite PR context.
4680
pr_number = pr_number or os.getenv('PR_NUMBER')
81+
if not pr_number and is_buildkite_pr:
82+
pr_number = buildkite_pr
4783

4884
# Add debug logging
49-
sha = os.getenv('GITHUB_SHA', '')
50-
log.debug(f"Loading SHA from GITHUB_SHA: {sha}")
85+
sha = os.getenv('GITHUB_SHA') or (
86+
os.getenv("BUILDKITE_COMMIT", "") if is_buildkite else ""
87+
)
88+
log.debug(f"Loading GitHub integration SHA: {sha}")
5189
event_action = os.getenv('EVENT_ACTION', None)
5290
if not event_action:
5391
event_path = os.getenv('GITHUB_EVENT_PATH')
5492
if event_path and os.path.exists(event_path):
5593
with open(event_path, 'r') as f:
5694
event = json.load(f)
5795
event_action = event.get('action')
96+
if not event_action and is_buildkite_pr:
97+
# Buildkite provides the current PR state, not the originating
98+
# GitHub webhook action. A running PR build is equivalent to the
99+
# supported synchronize path for comment updates.
100+
event_action = "synchronize"
58101
repository = os.getenv('GITHUB_REPOSITORY', '')
59102
owner = os.getenv('GITHUB_REPOSITORY_OWNER', '')
60103
if '/' in repository:
61104
owner = repository.split('/')[0]
62105
repository = repository.split('/')[1]
106+
elif is_buildkite:
107+
buildkite_owner, buildkite_repository = cls._repository_from_buildkite()
108+
owner = owner or buildkite_owner
109+
repository = repository or buildkite_repository
63110

64111
default_branch_env = os.getenv('DEFAULT_BRANCH')
65112
# Consider the variable truthy if it exists and isn't explicitly 'false'
66-
is_default = default_branch_env is not None and default_branch_env.lower() != 'false'
113+
if default_branch_env is not None:
114+
is_default = default_branch_env.lower() != 'false'
115+
elif is_buildkite:
116+
# Require a branch name: comparing two unset variables would otherwise report
117+
# every build as the default branch and overwrite the repository's baseline.
118+
buildkite_branch = os.getenv("BUILDKITE_BRANCH")
119+
is_default = bool(
120+
not is_buildkite_pr
121+
and buildkite_branch
122+
and buildkite_branch == os.getenv("BUILDKITE_PIPELINE_DEFAULT_BRANCH")
123+
)
124+
else:
125+
is_default = False
126+
127+
event_name = os.getenv('GITHUB_EVENT_NAME', '')
128+
if not event_name and is_buildkite:
129+
event_name = "pull_request" if is_buildkite_pr else "push"
67130
return cls(
68-
sha=os.getenv('GITHUB_SHA', ''),
69-
api_url=os.getenv('GITHUB_API_URL', ''),
70-
ref_type=os.getenv('GITHUB_REF_TYPE', ''),
71-
event_name=os.getenv('GITHUB_EVENT_NAME', ''),
72-
workspace=os.getenv('GITHUB_WORKSPACE', ''),
131+
sha=sha,
132+
api_url=os.getenv('GITHUB_API_URL') or (
133+
"https://api.github.com" if is_buildkite else ""
134+
),
135+
ref_type=os.getenv('GITHUB_REF_TYPE') or (
136+
"branch" if is_buildkite else ""
137+
),
138+
event_name=event_name,
139+
workspace=os.getenv('GITHUB_WORKSPACE') or (
140+
os.getenv("BUILDKITE_BUILD_CHECKOUT_PATH", "") if is_buildkite else ""
141+
),
73142
repository=repository,
74-
ref_name=os.getenv('GITHUB_REF_NAME', ''),
143+
ref_name=os.getenv('GITHUB_REF_NAME') or (
144+
os.getenv("BUILDKITE_BRANCH", "") if is_buildkite else ""
145+
),
75146
default_branch=is_default,
76147
is_default_branch=is_default,
77148
pr_number=pr_number,
78149
pr_name=os.getenv('PR_NAME'),
79-
commit_message=os.getenv('COMMIT_MESSAGE'),
80-
actor=os.getenv('GITHUB_ACTOR', ''),
150+
commit_message=os.getenv('COMMIT_MESSAGE') or (
151+
os.getenv("BUILDKITE_MESSAGE") if is_buildkite else None
152+
),
153+
actor=os.getenv('GITHUB_ACTOR') or (
154+
os.getenv("BUILDKITE_BUILD_CREATOR", "") if is_buildkite else ""
155+
),
81156
env=os.getenv('GITHUB_ENV', ''),
82157
token=token,
83158
owner=owner,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import pytest
2+
3+
from socketsecurity.core.scm.github import Github, GithubConfig
4+
5+
CONTEXT_VARIABLES = (
6+
"BUILDKITE",
7+
"BUILDKITE_BRANCH",
8+
"BUILDKITE_BUILD_CHECKOUT_PATH",
9+
"BUILDKITE_BUILD_CREATOR",
10+
"BUILDKITE_COMMIT",
11+
"BUILDKITE_MESSAGE",
12+
"BUILDKITE_PIPELINE_DEFAULT_BRANCH",
13+
"BUILDKITE_PULL_REQUEST",
14+
"BUILDKITE_PULL_REQUEST_REPO",
15+
"BUILDKITE_REPO",
16+
"DEFAULT_BRANCH",
17+
"EVENT_ACTION",
18+
"GH_API_TOKEN",
19+
"GITHUB_ACTOR",
20+
"GITHUB_API_URL",
21+
"GITHUB_EVENT_NAME",
22+
"GITHUB_EVENT_PATH",
23+
"GITHUB_REF_NAME",
24+
"GITHUB_REF_TYPE",
25+
"GITHUB_REPOSITORY",
26+
"GITHUB_REPOSITORY_OWNER",
27+
"GITHUB_SHA",
28+
"GITHUB_WORKSPACE",
29+
"PR_NUMBER",
30+
)
31+
32+
33+
@pytest.fixture(autouse=True)
34+
def clear_context(monkeypatch):
35+
for variable in CONTEXT_VARIABLES:
36+
monkeypatch.delenv(variable, raising=False)
37+
monkeypatch.setenv("GH_API_TOKEN", "test-token")
38+
39+
40+
def test_github_config_uses_native_buildkite_pull_request_context(monkeypatch):
41+
values = {
42+
"BUILDKITE": "true",
43+
"BUILDKITE_BRANCH": "feature/socket",
44+
"BUILDKITE_BUILD_CHECKOUT_PATH": "/workspace/repo",
45+
"BUILDKITE_BUILD_CREATOR": "octocat",
46+
"BUILDKITE_COMMIT": "a" * 40,
47+
"BUILDKITE_MESSAGE": "Update dependencies",
48+
"BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main",
49+
"BUILDKITE_PULL_REQUEST": "123",
50+
"BUILDKITE_PULL_REQUEST_REPO": "git@github.com:acme/widgets.git",
51+
"BUILDKITE_REPO": "git@github.com:acme/widgets.git",
52+
}
53+
for name, value in values.items():
54+
monkeypatch.setenv(name, value)
55+
56+
config = GithubConfig.from_env()
57+
58+
assert config.sha == "a" * 40
59+
assert config.api_url == "https://api.github.com"
60+
assert config.ref_type == "branch"
61+
assert config.event_name == "pull_request"
62+
assert config.event_action == "synchronize"
63+
assert config.workspace == "/workspace/repo"
64+
assert config.owner == "acme"
65+
assert config.repository == "widgets"
66+
assert config.ref_name == "feature/socket"
67+
assert config.pr_number == "123"
68+
assert config.commit_message == "Update dependencies"
69+
assert config.actor == "octocat"
70+
assert config.is_default_branch is False
71+
assert Github(client=object(), config=config).check_event_type() == "diff"
72+
73+
74+
def test_buildkite_non_pr_build_uses_push_and_default_branch(monkeypatch):
75+
values = {
76+
"BUILDKITE": "true",
77+
"BUILDKITE_BRANCH": "main",
78+
"BUILDKITE_COMMIT": "b" * 40,
79+
"BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main",
80+
"BUILDKITE_PULL_REQUEST": "false",
81+
"BUILDKITE_REPO": "https://github.com/acme/widgets.git",
82+
}
83+
for name, value in values.items():
84+
monkeypatch.setenv(name, value)
85+
86+
config = GithubConfig.from_env()
87+
88+
assert config.event_name == "push"
89+
assert config.pr_number is None
90+
assert config.owner == "acme"
91+
assert config.repository == "widgets"
92+
assert config.is_default_branch is True
93+
assert Github(client=object(), config=config).check_event_type() == "main"
94+
95+
96+
@pytest.mark.parametrize(
97+
"branch_variables",
98+
[
99+
{},
100+
{"BUILDKITE_BRANCH": "feature/socket"},
101+
{"BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main"},
102+
],
103+
)
104+
def test_buildkite_default_branch_requires_a_matching_branch_name(
105+
monkeypatch, branch_variables
106+
):
107+
"""Absent branch context must not be read as 'this build is the default branch'."""
108+
monkeypatch.setenv("BUILDKITE", "true")
109+
for name, value in branch_variables.items():
110+
monkeypatch.setenv(name, value)
111+
112+
config = GithubConfig.from_env()
113+
114+
assert config.is_default_branch is False
115+
assert config.default_branch is False
116+
117+
118+
def test_explicit_github_values_take_priority_in_buildkite(monkeypatch):
119+
values = {
120+
"BUILDKITE": "true",
121+
"BUILDKITE_BRANCH": "buildkite-branch",
122+
"BUILDKITE_COMMIT": "b" * 40,
123+
"BUILDKITE_PULL_REQUEST": "123",
124+
"BUILDKITE_REPO": "git@github.com:buildkite/repository.git",
125+
"EVENT_ACTION": "opened",
126+
"GITHUB_API_URL": "https://github.example/api/v3",
127+
"GITHUB_EVENT_NAME": "pull_request",
128+
"GITHUB_REF_NAME": "github-branch",
129+
"GITHUB_REF_TYPE": "branch",
130+
"GITHUB_REPOSITORY": "github/repository",
131+
"GITHUB_SHA": "c" * 40,
132+
"GITHUB_WORKSPACE": "/github/workspace",
133+
"PR_NUMBER": "456",
134+
}
135+
for name, value in values.items():
136+
monkeypatch.setenv(name, value)
137+
138+
config = GithubConfig.from_env()
139+
140+
assert config.sha == "c" * 40
141+
assert config.api_url == "https://github.example/api/v3"
142+
assert config.workspace == "/github/workspace"
143+
assert config.owner == "github"
144+
assert config.repository == "repository"
145+
assert config.ref_name == "github-branch"
146+
assert config.pr_number == "456"
147+
assert config.event_action == "opened"
148+
149+
150+
@pytest.mark.parametrize(
151+
("repository_url", "expected"),
152+
[
153+
("git@github.com:acme/widgets.git", ("acme", "widgets")),
154+
("https://github.com/acme/widgets.git", ("acme", "widgets")),
155+
("ssh://git@github.com/acme/widgets.git", ("acme", "widgets")),
156+
("", ("", "")),
157+
("not-a-repository", ("", "")),
158+
],
159+
)
160+
def test_buildkite_repository_url_parsing(monkeypatch, repository_url, expected):
161+
monkeypatch.setenv("BUILDKITE_REPO", repository_url)
162+
163+
assert GithubConfig._repository_from_buildkite() == expected
164+
165+
166+
def test_buildkite_pipeline_repository_wins_over_pull_request_fork(monkeypatch):
167+
monkeypatch.setenv("BUILDKITE_REPO", "git@github.com:acme/widgets.git")
168+
monkeypatch.setenv(
169+
"BUILDKITE_PULL_REQUEST_REPO",
170+
"git@github.com:contributor/widgets.git",
171+
)
172+
173+
assert GithubConfig._repository_from_buildkite() == ("acme", "widgets")

0 commit comments

Comments
 (0)