Skip to content

Commit 14bea6c

Browse files
committed
adding 3pl guard
1 parent 1485923 commit 14bea6c

3 files changed

Lines changed: 227 additions & 8 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Brief description of what this PR does.
66

77
How was this tested?
88

9+
## Dependencies
10+
11+
- [ ] No net-new third-party dependencies were added
12+
- [ ] If net-new third-party dependencies were added, rationale/discussion is included and `3pl-approved` is set by a maintainer
13+
914
---
1015

1116
- [ ] Tests pass (`pnpm test`)

.github/workflows/3pl-guard.yml

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
name: 3PL Guard
2+
3+
on:
4+
pull_request_target:
5+
types:
6+
- opened
7+
- reopened
8+
- synchronize
9+
- ready_for_review
10+
- labeled
11+
- unlabeled
12+
13+
permissions:
14+
contents: read
15+
pull-requests: write
16+
issues: write
17+
18+
jobs:
19+
dependency-review:
20+
name: Net-new 3PL check
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- name: Detect net-new dependencies and enforce review label
25+
uses: actions/github-script@v7
26+
with:
27+
script: |
28+
const reviewLabel = 'needs-3pl-review';
29+
const approvalLabel = '3pl-approved';
30+
const localProtocols = ['workspace:', 'file:', 'link:'];
31+
const dependencySections = [
32+
'dependencies',
33+
'devDependencies',
34+
'peerDependencies',
35+
'optionalDependencies',
36+
];
37+
38+
const pr = context.payload.pull_request;
39+
const baseRepo = pr.base.repo;
40+
const headRepo = pr.head.repo;
41+
const owner = context.repo.owner;
42+
const repo = context.repo.repo;
43+
const pullNumber = pr.number;
44+
45+
async function ensureLabel(name, color, description) {
46+
try {
47+
await github.rest.issues.getLabel({ owner, repo, name });
48+
} catch (error) {
49+
if (error.status !== 404) throw error;
50+
await github.rest.issues.createLabel({
51+
owner,
52+
repo,
53+
name,
54+
color,
55+
description,
56+
});
57+
}
58+
}
59+
60+
async function removeLabelIfPresent(name) {
61+
try {
62+
await github.rest.issues.removeLabel({
63+
owner,
64+
repo,
65+
issue_number: pullNumber,
66+
name,
67+
});
68+
} catch (error) {
69+
if (error.status !== 404) throw error;
70+
}
71+
}
72+
73+
async function addLabel(name) {
74+
await github.rest.issues.addLabels({
75+
owner,
76+
repo,
77+
issue_number: pullNumber,
78+
labels: [name],
79+
});
80+
}
81+
82+
async function getPackageJson({ owner, repo, path, ref }) {
83+
try {
84+
const response = await github.rest.repos.getContent({
85+
owner,
86+
repo,
87+
path,
88+
ref,
89+
});
90+
91+
if (!('content' in response.data)) return null;
92+
93+
const decoded = Buffer.from(response.data.content, 'base64').toString('utf8');
94+
return JSON.parse(decoded);
95+
} catch (error) {
96+
if (error.status === 404) return null;
97+
throw error;
98+
}
99+
}
100+
101+
function collectExternalDeps(packageJson) {
102+
const deps = new Set();
103+
if (!packageJson || typeof packageJson !== 'object') return deps;
104+
105+
for (const section of dependencySections) {
106+
const values = packageJson[section];
107+
if (!values || typeof values !== 'object') continue;
108+
109+
for (const [name, spec] of Object.entries(values)) {
110+
if (typeof spec !== 'string') continue;
111+
if (localProtocols.some((protocol) => spec.startsWith(protocol))) continue;
112+
deps.add(name);
113+
}
114+
}
115+
116+
return deps;
117+
}
118+
119+
const files = await github.paginate(github.rest.pulls.listFiles, {
120+
owner,
121+
repo,
122+
pull_number: pullNumber,
123+
per_page: 100,
124+
});
125+
126+
const changedPackageJsonFiles = files
127+
.map((file) => file.filename)
128+
.filter((filename) => filename.endsWith('package.json'));
129+
130+
const findings = [];
131+
132+
for (const path of changedPackageJsonFiles) {
133+
const basePackageJson = await getPackageJson({
134+
owner: baseRepo.owner.login,
135+
repo: baseRepo.name,
136+
path,
137+
ref: pr.base.sha,
138+
});
139+
const headPackageJson = await getPackageJson({
140+
owner: headRepo.owner.login,
141+
repo: headRepo.name,
142+
path,
143+
ref: pr.head.sha,
144+
});
145+
146+
const baseDeps = collectExternalDeps(basePackageJson);
147+
const headDeps = collectExternalDeps(headPackageJson);
148+
const added = [...headDeps].filter((dependency) => !baseDeps.has(dependency)).sort();
149+
150+
if (added.length > 0) {
151+
findings.push({ path, added });
152+
}
153+
}
154+
155+
const allNetNewDeps = [...new Set(findings.flatMap((item) => item.added))].sort();
156+
const hasApprovalLabel = pr.labels.some((label) => label.name === approvalLabel);
157+
158+
await ensureLabel(
159+
reviewLabel,
160+
'd73a4a',
161+
'PR introduces net-new third-party dependencies and needs discussion',
162+
);
163+
await ensureLabel(
164+
approvalLabel,
165+
'0e8a16',
166+
'Maintainer approved net-new third-party dependency additions',
167+
);
168+
169+
core.summary.addHeading('3PL dependency guard');
170+
171+
if (allNetNewDeps.length === 0) {
172+
await removeLabelIfPresent(reviewLabel);
173+
await core.summary
174+
.addRaw('No net-new third-party dependencies detected across changed package manifests.')
175+
.write();
176+
return;
177+
}
178+
179+
const manifestLines = findings.map((finding) => {
180+
const dependencies = finding.added.map((name) => `\`${name}\``).join(', ');
181+
return `- \`${finding.path}\`: ${dependencies}`;
182+
});
183+
184+
await core.summary
185+
.addRaw('Net-new third-party dependencies detected:\n\n')
186+
.addRaw(manifestLines.join('\n'))
187+
.addRaw('\n\n')
188+
.addRaw(`All net-new packages: ${allNetNewDeps.map((name) => `\`${name}\``).join(', ')}`)
189+
.addRaw('\n\n')
190+
.addRaw(
191+
`Blocking until a maintainer adds the \`${approvalLabel}\` label after dependency review discussion.`,
192+
)
193+
.write();
194+
195+
if (hasApprovalLabel) {
196+
await removeLabelIfPresent(reviewLabel);
197+
return;
198+
}
199+
200+
await addLabel(reviewLabel);
201+
core.setFailed(
202+
`Net-new third-party dependencies found: ${allNetNewDeps.join(', ')}. Add \`${approvalLabel}\` after review.`,
203+
);

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,18 @@
77
88
Salesforce Commerce Cloud B2C Command Line Tools.
99

10-
1110
> [!TIP]
1211
> **Just looking for the B2C CLI or MCP install instructions?** Visit the documentation site at [https://salesforcecommercecloud.github.io/b2c-developer-tooling/](https://salesforcecommercecloud.github.io/b2c-developer-tooling/) for the latest install guide and CLI reference.
1312
1413
## Packages
1514

1615
This is a pnpm monorepo with the following packages:
1716

18-
| Package | Description |
19-
|---------|-------------|
20-
| [`b2c-cli`](./packages/b2c-cli/README.md) | Command line interface built with oclif |
17+
| Package | Description |
18+
| --------------------------------------------------------- | ------------------------------------------------------------------------------------ |
19+
| [`b2c-cli`](./packages/b2c-cli/README.md) | Command line interface built with oclif |
2120
| [`b2c-tooling-sdk`](./packages/b2c-tooling-sdk/README.md) | SDK/library for B2C Commerce operations; supports the CLI and can be used standalone |
22-
| [`b2c-dx-mcp`](./packages/b2c-dx-mcp/README.md) | MCP server for B2C Commerce developer experience tools |
21+
| [`b2c-dx-mcp`](./packages/b2c-dx-mcp/README.md) | MCP server for B2C Commerce developer experience tools |
2322

2423
## Development
2524

@@ -85,6 +84,7 @@ pnpm mocha --grep "uploads a file" "test/**/*.test.ts"
8584
#### Coverage
8685

8786
Coverage reports are generated in each package's `coverage/` directory:
87+
8888
- `coverage/index.html` - HTML report
8989
- `coverage/lcov.info` - LCOV format for CI integration
9090

@@ -184,9 +184,9 @@ Preview releases are available as tgz files on [GitHub Releases](https://github.
184184
The `@salesforce/b2c-tooling-sdk` package uses the `exports` field in package.json to define its public API surface. Each module is available as a subpath export:
185185

186186
```typescript
187-
import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth';
188-
import { B2CInstance } from '@salesforce/b2c-tooling-sdk/instance';
189-
import { getLogger } from '@salesforce/b2c-tooling-sdk/logging';
187+
import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';
188+
import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance';
189+
import {getLogger} from '@salesforce/b2c-tooling-sdk/logging';
190190
```
191191

192192
The `development` condition in exports enables direct TypeScript source resolution when using `--conditions=development`, which is how `bin/dev.js` works for local development.
@@ -195,6 +195,17 @@ The `development` condition in exports enables direct TypeScript source resoluti
195195

196196
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on how to get started, submit pull requests, and our code of conduct.
197197

198+
### Third-Party Dependency Review
199+
200+
To prevent net-new third-party libraries from being added without discussion, PRs are checked by the `3PL Guard` workflow (`Net-new 3PL check`).
201+
202+
- The check compares changed `package.json` files in the PR and detects net-new external dependencies.
203+
- If net-new dependencies are found, the PR is labeled `needs-3pl-review` and the check fails.
204+
- After discussion and approval, a maintainer adds the `3pl-approved` label to allow the check to pass.
205+
- If the net-new dependency is removed, `needs-3pl-review` is removed automatically.
206+
207+
To enforce this as a merge gate, keep `Net-new 3PL check` as a required status check in branch protection.
208+
198209
## Security
199210

200211
For security concerns, please review our [Security Policy](./SECURITY.md). Report any security issues to [security@salesforce.com](mailto:security@salesforce.com).

0 commit comments

Comments
 (0)