Skip to content

Commit 9e9bc58

Browse files
leliaclaude
andcommitted
fix(ci): keep branch pipelines out of pull request handling
Two ways an SCM branch build could still be treated like a pull request: Buildkite always sets BUILDKITE_PULL_REQUEST, to the string "false" on a branch build, so the documented --pr-number "$BUILDKITE_PULL_REQUEST" form delivers a truthy non-numeric value. resolve_pull_request_context read it as no PR but only wrote the normalized number back when one was found, so GithubConfig still saw "false", check_event_type returned "diff" for a push, and comment lookups went to issues/false/comments. Canonicalize config.pr_number before any adapter reads it. A branch run creating a full scan then blocked on diff.new_alerts, which a full scan cannot fill meaningfully: empty with no alert-bearing output format enabled, and every alert in the scan rather than the newly introduced ones with one. The exit code therefore depended on which output format was requested. Treat these runs the way a run with no supported manifest files is already treated and skip blocking, leaving pull request pipelines to enforce policy. Move the scan-type decision into create_scm_scan, which returns the diff and whether it came from a comparison, so the branch is exercised by tests rather than only its predicate. Document both the scan-type table and the blocking consequence in the CI/CD guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8f9e402 commit 9e9bc58

5 files changed

Lines changed: 224 additions & 43 deletions

File tree

‎docs/ci-cd.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,12 +455,36 @@ pipelines:
455455
- socketcli --config .socketcli.toml --target-path .
456456
```
457457

458+
## Scan type by pipeline
459+
460+
With `--scm github` or `--scm gitlab`, the detected event decides the scan type:
461+
462+
| Event | Scan | Blocks the build |
463+
|:------|:-----|:-----------------|
464+
| Pull request / merge request | Diff scan against the repository's baseline | Yes, on newly introduced alerts |
465+
| Any other pipeline, including default-branch pushes | Full scan | No |
466+
467+
A full scan has no baseline, so it cannot tell a newly introduced alert from one
468+
that was already there. Rather than block on a number that would mean something
469+
different depending on which output format was enabled, those runs behave as if
470+
`--disable-blocking` was supplied and report through the Dashboard instead. This
471+
matches how the CLI already treats a run with no supported manifest files.
472+
473+
The event type is authoritative once `--scm` is set: `--enable-diff` and
474+
`--ignore-commit-files` do not turn a branch pipeline into a comparison. To diff
475+
a branch build, drop `--scm` and use `--enable-diff` with `--integration`, which
476+
runs the comparison without the PR comment adapter.
477+
478+
`--generate-license` and `--legal-format fossa` work on both paths; a full scan
479+
fetches the package list for them.
480+
458481
## Pull request and Dashboard association
459482

460483
The CLI sends the resolved pull request number with each full scan and attaches
461484
the pull request URL to diff scans so the Socket Dashboard can associate the
462485
report with its originating change. If `--pr-number` is supplied, it wins;
463-
passing `--pr-number 0` explicitly disables automatic association.
486+
passing `--pr-number 0` explicitly disables automatic association. Any value that
487+
is not a positive integer, including Buildkite's `false`, means no pull request.
464488

465489
Without an explicit value, the CLI recognizes:
466490

‎socketsecurity/core/pull_request.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ class PullRequestContext:
1212
url: Optional[str] = None
1313

1414

15-
def _positive_int(value) -> int:
15+
def parse_pull_request_number(value) -> int:
16+
"""Coerce a configured or CI-supplied pull request number to a positive int.
17+
18+
Anything that is not a positive integer means "no pull request", including the
19+
literal ``false`` that Buildkite puts in ``BUILDKITE_PULL_REQUEST`` on non-PR
20+
builds. Callers that hand the value on to a comment adapter should store this
21+
result rather than the raw string, which is truthy.
22+
"""
1623
try:
1724
parsed = int(value)
1825
except (TypeError, ValueError):
@@ -31,11 +38,11 @@ def _repository_url(value: Optional[str]) -> Optional[str]:
3138

3239

3340
def _github_number(env: Mapping[str, str]) -> int:
34-
number = _positive_int(env.get("PR_NUMBER"))
41+
number = parse_pull_request_number(env.get("PR_NUMBER"))
3542
if number:
3643
return number
3744
match = re.match(r"^refs/pull/(\d+)/", env.get("GITHUB_REF", ""))
38-
return _positive_int(match.group(1)) if match else 0
45+
return parse_pull_request_number(match.group(1)) if match else 0
3946

4047

4148
def _github_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]:
@@ -89,17 +96,17 @@ def resolve_pull_request_context(
8996
"""
9097
environment = env or {}
9198
provider = str(integration_type or "api").lower()
92-
number = _positive_int(configured_number)
99+
number = parse_pull_request_number(configured_number)
93100

94101
if not configured_explicit and not number:
95102
if provider == "github":
96103
number = _github_number(environment)
97104
elif provider == "gitlab":
98-
number = _positive_int(environment.get("CI_MERGE_REQUEST_IID"))
105+
number = parse_pull_request_number(environment.get("CI_MERGE_REQUEST_IID"))
99106
elif provider == "azure":
100107
number = (
101-
_positive_int(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) or
102-
_positive_int(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID"))
108+
parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) or
109+
parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID"))
103110
)
104111

105112
if not number:

‎socketsecurity/socketcli.py‎

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import traceback
66
from datetime import datetime, timezone
7+
from typing import List, Optional, Tuple
78
from uuid import uuid4
89

910
from dotenv import load_dotenv
@@ -18,7 +19,10 @@
1819
from socketsecurity.core.git_interface import Git
1920
from socketsecurity.core.logging import initialize_logging, set_debug_mode
2021
from socketsecurity.core.messages import Messages
21-
from socketsecurity.core.pull_request import resolve_pull_request_context
22+
from socketsecurity.core.pull_request import (
23+
parse_pull_request_number,
24+
resolve_pull_request_context,
25+
)
2226
from socketsecurity.core.scm_comments import Comments
2327
from socketsecurity.core.socket_config import SocketConfig, module_folder_dirs
2428
from socketsecurity.core.streaming import StreamingLogs
@@ -139,8 +143,47 @@ def _select_pull_request_provider(integration_type: str, scm_type: str) -> str:
139143
return scm_type if scm_type in ("github", "gitlab") else integration_type
140144

141145

142-
def _should_create_scm_diff(event_type: str) -> bool:
143-
return event_type == "diff"
146+
def create_scm_scan(
147+
core: Core,
148+
config: CliConfig,
149+
scm_event_type: Optional[str],
150+
*,
151+
scan_paths: List[str],
152+
params: FullScanParams,
153+
no_change: bool,
154+
base_paths: Optional[List[str]],
155+
explicit_files: Optional[List[str]],
156+
external_href: Optional[str],
157+
) -> Tuple[Diff, bool]:
158+
"""Create the scan for an SCM-integrated run.
159+
160+
Only a pull request or merge request event has a baseline to compare against,
161+
so every other pipeline -- default-branch pushes included -- gets a full scan.
162+
The detected event type is authoritative: API-only diff flags cannot turn an
163+
ordinary branch pipeline into a comparison.
164+
165+
Returns the diff and whether it came from a comparison. Callers need the second
166+
value because a full scan carries no "new alerts" category to comment on or to
167+
block a build with.
168+
"""
169+
scan_kwargs = {
170+
"no_change": no_change,
171+
"save_files_list_path": config.save_submitted_files_list,
172+
"save_manifest_tar_path": config.save_manifest_tar,
173+
"base_paths": base_paths,
174+
"explicit_files": explicit_files,
175+
}
176+
if scm_event_type == "diff":
177+
log.info("Starting comment logic for PR/MR event")
178+
diff = core.create_new_diff(
179+
scan_paths, params, external_href=external_href, **scan_kwargs
180+
)
181+
return diff, True
182+
183+
log.info("Starting non-PR/MR flow")
184+
# No before/after pair here, so there is nothing for external_href to hang off.
185+
diff = core.create_full_scan_with_report_url(scan_paths, params, **scan_kwargs)
186+
return diff, False
144187

145188

146189
def build_socket_sdk(config: CliConfig) -> socketdev:
@@ -507,6 +550,13 @@ def main_code():
507550

508551
log.info("Continuing with normal scan flow...")
509552

553+
# Canonicalize before any adapter reads it. Buildkite always sets
554+
# BUILDKITE_PULL_REQUEST -- to the string "false" on non-PR builds -- so the
555+
# documented --pr-number "$BUILDKITE_PULL_REQUEST" form delivers a truthy
556+
# non-numeric value that GithubConfig would otherwise treat as a real PR,
557+
# making a branch build look like a pull request event.
558+
config.pr_number = str(parse_pull_request_number(config.pr_number))
559+
510560
scm = None
511561
if config.scm == "github":
512562
from socketsecurity.core.scm.github import Github, GithubConfig
@@ -689,6 +739,10 @@ def _is_unprocessed(c):
689739
return True
690740

691741
scm_event_type = scm.check_event_type() if scm is not None else None
742+
# Every branch below except the SCM full-scan one produces a comparison, or
743+
# is already covered by force_api_mode. See the blocking guard after the
744+
# scan for why this is tracked.
745+
comparison_ran = True
692746
if scm_event_type == "comment":
693747
# FIXME: This entire flow should be a separate command called "filter_ignored_alerts_in_comments"
694748
# It's not related to scanning or diff generation - it just:
@@ -745,18 +799,18 @@ def _is_unprocessed(c):
745799

746800
elif scm is not None and not force_api_mode:
747801
log.info("Push initiated flow")
748-
if _should_create_scm_diff(scm_event_type):
749-
log.info("Starting comment logic for PR/MR event")
750-
diff = core.create_new_diff(
751-
scan_paths,
752-
params,
753-
no_change=should_skip_scan,
754-
save_files_list_path=config.save_submitted_files_list,
755-
save_manifest_tar_path=config.save_manifest_tar,
756-
base_paths=base_paths,
757-
explicit_files=scan_explicit_files,
758-
external_href=pr_context.url,
759-
)
802+
diff, comparison_ran = create_scm_scan(
803+
core,
804+
config,
805+
scm_event_type,
806+
scan_paths=scan_paths,
807+
params=params,
808+
no_change=should_skip_scan,
809+
base_paths=base_paths,
810+
explicit_files=scan_explicit_files,
811+
external_href=pr_context.url,
812+
)
813+
if comparison_ran:
760814
comments = scm.get_comments_for_pr()
761815

762816
# FIXME: this overwrites diff.new_alerts, which was previously populated by Core.create_issue_alerts
@@ -881,17 +935,6 @@ def _is_unprocessed(c):
881935
new_security_comment,
882936
new_overview_comment
883937
)
884-
else:
885-
log.info("Starting non-PR/MR flow")
886-
diff = core.create_full_scan_with_report_url(
887-
scan_paths,
888-
params,
889-
no_change=should_skip_scan,
890-
save_files_list_path=config.save_submitted_files_list,
891-
save_manifest_tar_path=config.save_manifest_tar,
892-
base_paths=base_paths,
893-
explicit_files=scan_explicit_files,
894-
)
895938

896939
output_handler.handle_output(diff)
897940

@@ -991,13 +1034,20 @@ def _is_unprocessed(c):
9911034
)
9921035
_write_attribution_file(config, all_packages)
9931036

994-
# If we forced API mode due to no supported files, behave as if --disable-blocking was set
995-
if force_api_mode:
1037+
# A run that created a full scan instead of a comparison has no baseline, so
1038+
# diff.new_alerts is not a meaningful thing to block on: with no alert-bearing
1039+
# output format enabled it is empty, and with --enable-json/--sarif/
1040+
# --enable-gitlab-security it holds every alert in the scan rather than the
1041+
# newly introduced ones. Blocking on it would make the exit code depend on
1042+
# which output format happened to be requested, so behave as if
1043+
# --disable-blocking was set. force_api_mode arrives here for the same reason
1044+
# (no supported manifest files, so nothing to compare).
1045+
if force_api_mode or not comparison_ran:
9961046
if config.strict_blocking:
9971047
log.warning("--strict-blocking is only supported in diff mode. "
998-
"API mode (no diff) cannot evaluate existing violations.")
1048+
"A full scan (no diff) cannot evaluate existing violations.")
9991049
if not config.disable_blocking:
1000-
log.debug("Temporarily enabling disable_blocking due to no supported manifest files")
1050+
log.debug("Temporarily enabling disable_blocking: this run created a full scan, not a comparison")
10011051
config.disable_blocking = True
10021052

10031053
# Post commit status to GitLab if enabled

‎tests/unit/test_pull_request_context.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1-
from socketsecurity.core.pull_request import resolve_pull_request_context
1+
import pytest
2+
3+
from socketsecurity.core.pull_request import (
4+
parse_pull_request_number,
5+
resolve_pull_request_context,
6+
)
7+
8+
9+
@pytest.mark.parametrize(
10+
"value",
11+
# "false" is what Buildkite puts in BUILDKITE_PULL_REQUEST on a branch build.
12+
# main_code stores this result rather than the raw value, because the string
13+
# is truthy and GithubConfig would read it as a real pull request number.
14+
["false", "0", "", None, "-1", "not-a-number"],
15+
)
16+
def test_non_pull_request_values_canonicalize_to_zero(value):
17+
assert parse_pull_request_number(value) == 0
18+
19+
20+
def test_pull_request_numbers_survive_canonicalization():
21+
assert parse_pull_request_number("42") == 42
22+
assert parse_pull_request_number(42) == 42
223

324

425
def test_explicit_pr_number_wins_over_detected_context():

‎tests/unit/test_socketcli.py‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from socketsecurity import socketcli
6+
from socketsecurity.config import CliConfig
67
from socketsecurity.core.classes import Diff, Package
78
from socketsecurity.socketcli import (
89
build_license_artifact_payload,
@@ -74,12 +75,90 @@ def test_pr_context_provider_uses_integration_without_comment_adapter():
7475
assert socketcli._select_pull_request_provider("azure", "api") == "azure"
7576

7677

77-
def test_scm_merge_request_event_creates_diff():
78-
assert socketcli._should_create_scm_diff("diff") is True
78+
# ---------------------------------------------------------------------------
79+
# SCM scan selection.
80+
#
81+
# Only a pull request or merge request event has a baseline, so every other
82+
# pipeline gets a full scan. These drive create_scm_scan against a recording
83+
# stub rather than asserting on the branch predicate, so swapping the call back
84+
# to create_new_diff fails them.
85+
# ---------------------------------------------------------------------------
7986

8087

81-
def test_scm_branch_event_always_uses_full_scan():
82-
assert socketcli._should_create_scm_diff("main") is False
88+
class _RecordingCore:
89+
def __init__(self):
90+
self.calls = []
91+
92+
def create_new_diff(self, *args, **kwargs):
93+
self.calls.append(("create_new_diff", args, kwargs))
94+
return Diff(id="diff-scan")
95+
96+
def create_full_scan_with_report_url(self, *args, **kwargs):
97+
self.calls.append(("create_full_scan_with_report_url", args, kwargs))
98+
return Diff(id="full-scan")
99+
100+
101+
def _run_scm_scan(scm_event_type, **overrides):
102+
core = _RecordingCore()
103+
config = CliConfig.from_args(["--api-token", "test"])
104+
diff, comparison_ran = socketcli.create_scm_scan(
105+
core,
106+
config,
107+
scm_event_type,
108+
**{
109+
"scan_paths": ["."],
110+
"params": object(),
111+
"no_change": False,
112+
"base_paths": None,
113+
"explicit_files": None,
114+
"external_href": "https://github.com/acme/widgets/pull/42",
115+
**overrides,
116+
},
117+
)
118+
return core, diff, comparison_ran
119+
120+
121+
def test_pull_request_event_creates_a_comparison_with_the_pr_link():
122+
core, diff, comparison_ran = _run_scm_scan("diff")
123+
124+
method, _, kwargs = core.calls[0]
125+
assert method == "create_new_diff"
126+
assert kwargs["external_href"] == "https://github.com/acme/widgets/pull/42"
127+
assert comparison_ran is True
128+
assert diff.id == "diff-scan"
129+
130+
131+
@pytest.mark.parametrize("scm_event_type", ["main", None])
132+
def test_branch_event_creates_a_full_scan(scm_event_type):
133+
core, diff, comparison_ran = _run_scm_scan(scm_event_type)
134+
135+
method, _, kwargs = core.calls[0]
136+
assert method == "create_full_scan_with_report_url"
137+
# A full scan has no before/after pair to associate the link with.
138+
assert "external_href" not in kwargs
139+
assert comparison_ran is False
140+
assert diff.id == "full-scan"
141+
142+
143+
def test_api_only_diff_flags_do_not_force_a_comparison_on_a_branch_build():
144+
"""The detected event type is authoritative once an SCM adapter is active."""
145+
core = _RecordingCore()
146+
config = CliConfig.from_args(["--api-token", "test", "--enable-diff"])
147+
148+
_, comparison_ran = socketcli.create_scm_scan(
149+
core,
150+
config,
151+
"main",
152+
scan_paths=["."],
153+
params=object(),
154+
no_change=False,
155+
base_paths=None,
156+
explicit_files=None,
157+
external_href=None,
158+
)
159+
160+
assert core.calls[0][0] == "create_full_scan_with_report_url"
161+
assert comparison_ran is False
83162

84163

85164
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)