@@ -71,6 +71,239 @@ Equivalent JSON:
7171 SOCKET_SECURITY_API_TOKEN : ${{ secrets.SOCKET_SECURITY_API_TOKEN }}
7272` ` `
7373
74+ #### GitHub Actions: scan changed monorepo workspaces independently
75+
76+ GitHub Actions ` paths` filters only decide whether a workflow starts. They do not
77+ change `socketcli` discovery or upload scope. For a merge gate, it is usually safer
78+ to start a small selector job on every PR update, then create one scan job per
79+ affected logical workspace. This also avoids a required check remaining pending
80+ when GitHub skips the entire workflow because of a top-level path filter.
81+
82+ This pattern produces one dashboard entry per logical workspace, which is what
83+ gives each component its own alerts, baseline, and policy. It is also the layout
84+ that grows the dashboard's repository list. See
85+ [Choosing a scan layout](cli-reference.md#choosing-a-scan-layout) for when that
86+ trade-off is worth making.
87+
88+ Define a repository variable named `SOCKET_MONOREPO_WORKSPACES_JSON`. Its value is
89+ an array with one stable workspace name, one or more scan roots, and the path globs
90+ that should select that workspace. Fill these placeholders with the repository's
91+ real layout. A workspace definition selects directory roots; shared root manifests,
92+ lockfiles, and cross-directory path dependencies outside those roots are not included
93+ automatically.
94+
95+ ` ` ` json
96+ [
97+ {
98+ "name": "<stable-workspace-name>",
99+ "sub_paths": ["<repo-relative-scan-root>"],
100+ "watch_globs": ["<repo-relative-changed-file-glob>"]
101+ }
102+ ]
103+ ` ` `
104+
105+ Each `sub_paths` value must be a directory, not an individual manifest or lockfile.
106+ Using `.` includes the entire target path. Do not use this changed-workspace pattern
107+ until the directory boundaries preserve every shared input needed to resolve each
108+ logical graph. If root workspace metadata governs most or all of the repository, a
109+ smaller coverage-preserving split may not be representable with `--sub-path` alone.
110+
111+ Also define `SOCKETCLI_VERSION` as the exact package version validated for the
112+ workflow. The workflow below logs that version, uses full Git history for reliable
113+ base/head selection, creates one matrix job (and therefore one graph and baseline)
114+ per selected workspace, and fails closed on CLI/API/timeout failures. It uses API
115+ SCM mode plus `--enable-diff` because parallel `--scm github` jobs can race while
116+ updating the same PR comments; the matrix checks and report links are the gate.
117+
118+ ` ` ` yaml
119+ name: Socket Security
120+
121+ on:
122+ pull_request:
123+ types: [opened, synchronize, reopened]
124+ push:
125+ branches: [main]
126+
127+ permissions:
128+ contents: read
129+
130+ jobs:
131+ select-workspaces:
132+ runs-on: ubuntu-latest
133+ outputs:
134+ count: ${{ steps.select.outputs.count }}
135+ matrix: ${{ steps.select.outputs.matrix }}
136+ steps:
137+ - uses: actions/checkout@v5
138+ with:
139+ fetch-depth: 0
140+ persist-credentials: false
141+
142+ - id: select
143+ name: Select changed workspaces
144+ env:
145+ WORKSPACES_JSON: ${{ vars.SOCKET_MONOREPO_WORKSPACES_JSON }}
146+ BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
147+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
148+ shell: bash
149+ run: |
150+ python - <<'PY'
151+ import fnmatch
152+ import json
153+ import os
154+ import re
155+ import subprocess
156+
157+ workspaces = json.loads(os.environ["WORKSPACES_JSON"])
158+ if not isinstance(workspaces, list):
159+ raise SystemExit("SOCKET_MONOREPO_WORKSPACES_JSON must be a JSON array")
160+
161+ base = os.environ["BASE_SHA"]
162+ head = os.environ["HEAD_SHA"]
163+ if not base or set(base) == {"0"}:
164+ base = subprocess.check_output(
165+ ["git", "rev-parse", f"{head}^"], text=True
166+ ).strip()
167+ changed_output = subprocess.check_output(
168+ ["git", "diff", "--name-only", "-z", base, head]
169+ )
170+ changed = [
171+ item.decode("utf-8", "surrogateescape")
172+ for item in changed_output.split(b"\0 ")
173+ if item
174+ ]
175+
176+ selected = []
177+ for workspace in workspaces:
178+ name = workspace.get("name", "")
179+ sub_paths = workspace.get("sub_paths") or []
180+ watch_globs = workspace.get("watch_globs") or []
181+ if not re.fullmatch(r"[A-Za-z0-9._-]+", name):
182+ raise SystemExit(f"Invalid workspace name: {name!r}")
183+ if not sub_paths or any(
184+ not isinstance(path, str)
185+ or path.startswith("/")
186+ or ".." in path.split("/")
187+ for path in sub_paths
188+ ):
189+ raise SystemExit(f"Invalid sub_paths for workspace {name!r}")
190+ if not watch_globs:
191+ watch_globs = [
192+ pattern
193+ for path in sub_paths
194+ for pattern in (
195+ ["*"]
196+ if path.strip("/") in ("", ".")
197+ else [path.rstrip("/"), f"{path.rstrip('/')}/*"]
198+ )
199+ ]
200+ if any(
201+ fnmatch.fnmatchcase(path, pattern)
202+ for path in changed
203+ for pattern in watch_globs
204+ ):
205+ selected.append({"name": name, "sub_paths": sub_paths})
206+
207+ matrix = json.dumps({"include": selected}, separators=(",", ":"))
208+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
209+ output.write(f"count={len(selected)}\n ")
210+ output.write(f"matrix={matrix}\n ")
211+ PY
212+
213+ scan-workspace:
214+ needs: select-workspaces
215+ if: needs.select-workspaces.outputs.count != '0'
216+ timeout-minutes: 20
217+ strategy:
218+ fail-fast: false
219+ matrix: ${{ fromJSON(needs.select-workspaces.outputs.matrix) }}
220+ name: Socket scan (${{ matrix.name }})
221+ runs-on: ubuntu-latest
222+ steps:
223+ - uses: actions/checkout@v5
224+ with:
225+ fetch-depth: 0
226+ persist-credentials: false
227+
228+ - uses: actions/setup-python@v6
229+ with:
230+ python-version: '3.12'
231+
232+ - name: Install pinned Socket CLI
233+ env:
234+ SOCKETCLI_VERSION: ${{ vars.SOCKETCLI_VERSION }}
235+ run: |
236+ python -m pip install "socketsecurity==$SOCKETCLI_VERSION"
237+ socketcli --version
238+
239+ - name: Scan workspace
240+ env:
241+ SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }}
242+ PR_NUMBER: ${{ github.event.pull_request.number || 0 }}
243+ WORKSPACE_NAME: ${{ matrix.name }}
244+ SUB_PATHS_JSON: ${{ toJSON(matrix.sub_paths) }}
245+ shell: bash
246+ run: |
247+ set +e
248+ args=(
249+ --target-path "$GITHUB_WORKSPACE"
250+ --workspace-name "$WORKSPACE_NAME"
251+ --enable-diff
252+ --pr-number "$PR_NUMBER"
253+ --exit-code-on-api-error 3
254+ --report-link-file socket-report-link.txt
255+ --summary-file socket-summary.txt
256+ )
257+ while IFS= read -r sub_path; do
258+ args+=(--sub-path "$sub_path")
259+ done < <(jq -r '.[]' <<<"$SUB_PATHS_JSON")
260+
261+ socketcli "${args[@]}" 2>&1 | tee socket-output.log
262+ code=${PIPESTATUS[0]}
263+
264+ {
265+ echo "## Socket scan: $WORKSPACE_NAME"
266+ if [ -s socket-report-link.txt ]; then
267+ echo "[View the report]($(cat socket-report-link.txt))"
268+ fi
269+ if [ -s socket-summary.txt ]; then
270+ echo '` ` ` '
271+ cat socket-summary.txt
272+ echo ' ` ` ` '
273+ fi
274+ } >> "$GITHUB_STEP_SUMMARY"
275+
276+ exit "$code"
277+
278+ socket-security:
279+ if: always()
280+ needs: [select-workspaces, scan-workspace]
281+ runs-on: ubuntu-latest
282+ steps:
283+ - name: Enforce matrix result
284+ env:
285+ SELECT_RESULT: ${{ needs.select-workspaces.result }}
286+ SCAN_RESULT: ${{ needs.scan-workspace.result }}
287+ run: |
288+ test "$SELECT_RESULT" = success
289+ [[ "$SCAN_RESULT" = success || "$SCAN_RESULT" = skipped ]]
290+ ` ` `
291+
292+ Each configuration object may intentionally contain several `sub_paths` when
293+ those directories are one logical dependency graph. To split backend resolution,
294+ use separate objects with different `name` values. Add `--workspace <name>` only
295+ when the Socket organization requires API workspace association; it is not a scan
296+ scope control. Use `--save-submitted-files-list` in a non-required canary to verify
297+ the exact manifests selected before adopting workspace-level scans as a merge gate.
298+
299+ The job has an explicit 20-minute total budget. Tune that value from observed
300+ workspace-level latency after the split; a five-minute cap can still be too close
301+ to a slow request plus local startup. The CLI's `--timeout` is different : it
302+ defaults to 1,200 seconds **per API request**. If an operator adds GNU `timeout`,
303+ that process supervisor can terminate the CLI before it maps an error through
304+ ` --exit-code-on-api-error` ; without `--preserve-status`, GNU reports 124 after its
305+ initial timeout signal or 137 if `SIGKILL` is involved.
306+
74307# ## Buildkite
75308
76309` ` ` yaml
0 commit comments