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