@juangcarmona/lighthouse-sdlc 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capabilities.json +26 -0
- package/package.json +3 -2
- package/src/capabilities.mjs +1 -0
- package/src/github-profile.mjs +305 -0
package/capabilities.json
CHANGED
|
@@ -49,6 +49,32 @@
|
|
|
49
49
|
"allowed": ["create", "preserve"],
|
|
50
50
|
"blocked": ["modify", "rename", "remove", "ambiguous"]
|
|
51
51
|
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "github-profile",
|
|
55
|
+
"provider": "github",
|
|
56
|
+
"adapter": "github-profile",
|
|
57
|
+
"version": "1",
|
|
58
|
+
"title": "Recommended GitHub repository profile (optional)",
|
|
59
|
+
"selection": "opt-in",
|
|
60
|
+
"configuration": {
|
|
61
|
+
"profileVersion": 1,
|
|
62
|
+
"configurationPath": ".lighthouse/github.json",
|
|
63
|
+
"guide": "https://github.com/juangcarmona/lighthouse/blob/main/src/packages/cli/github-profile.md"
|
|
64
|
+
},
|
|
65
|
+
"operations": {
|
|
66
|
+
"allowed": [
|
|
67
|
+
"create",
|
|
68
|
+
"preserve",
|
|
69
|
+
"modify"
|
|
70
|
+
],
|
|
71
|
+
"blocked": [
|
|
72
|
+
"rename",
|
|
73
|
+
"remove",
|
|
74
|
+
"ambiguous",
|
|
75
|
+
"unsupported"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
52
78
|
}
|
|
53
79
|
]
|
|
54
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juangcarmona/lighthouse-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
|
|
6
6
|
"repository": {
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"./command-aliases.json": "./command-aliases.json",
|
|
71
71
|
"./schemas/command-aliases.schema.json": "./schemas/command-aliases.schema.json",
|
|
72
72
|
"./workflows/*": "./workflows/*",
|
|
73
|
-
"./host-contract": "./src/host-contract.mjs"
|
|
73
|
+
"./host-contract": "./src/host-contract.mjs",
|
|
74
|
+
"./github-profile": "./src/github-profile.mjs"
|
|
74
75
|
}
|
|
75
76
|
}
|
package/src/capabilities.mjs
CHANGED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const githubProfileVersion = 1;
|
|
5
|
+
export const repositoryDefaults = Object.freeze({
|
|
6
|
+
has_issues: true,
|
|
7
|
+
has_discussions: false,
|
|
8
|
+
is_template: false,
|
|
9
|
+
allow_squash_merge: true,
|
|
10
|
+
allow_merge_commit: false,
|
|
11
|
+
allow_rebase_merge: false,
|
|
12
|
+
squash_merge_commit_title: 'PR_TITLE',
|
|
13
|
+
squash_merge_commit_message: 'PR_BODY',
|
|
14
|
+
allow_auto_merge: true,
|
|
15
|
+
allow_update_branch: true,
|
|
16
|
+
delete_branch_on_merge: true,
|
|
17
|
+
web_commit_signoff_required: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
const canonical = (value) => JSON.stringify(sort(value));
|
|
22
|
+
function sort(value) {
|
|
23
|
+
if (Array.isArray(value)) return value.map(sort);
|
|
24
|
+
if (record(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sort(value[key])]));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
const equal = (left, right) => canonical(left) === canonical(right);
|
|
28
|
+
const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex');
|
|
29
|
+
const safeRepo = (repo) => typeof repo === 'string' && /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.endsWith('/.') && !repo.endsWith('/..');
|
|
30
|
+
const defaults = {
|
|
31
|
+
...Object.fromEntries(Object.entries(repositoryDefaults).map(([key, value]) => [`repository.${key}`, value])),
|
|
32
|
+
'actions.workflow': { default_workflow_permissions: 'read', can_approve_pull_request_reviews: false },
|
|
33
|
+
'actions.forkApproval': 'all_external_contributors',
|
|
34
|
+
'security.alerts': true,
|
|
35
|
+
'security.updates': true,
|
|
36
|
+
'security.secret_scanning': 'enabled',
|
|
37
|
+
'security.secret_scanning_push_protection': 'enabled',
|
|
38
|
+
'releases.immutable': true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function validateGithubProfile(config) {
|
|
42
|
+
const errors = [];
|
|
43
|
+
if (!record(config)) return ['GitHub profile must be an object.'];
|
|
44
|
+
const keys = ['schemaVersion', 'profileVersion', 'repository', 'intake', 'requiredChecks', 'requiredApprovals', 'evidenceBranch', 'releaseWorkflowReady', 'overrides'];
|
|
45
|
+
for (const key of Object.keys(config)) if (!keys.includes(key)) errors.push(`Unknown profile field: ${key}.`);
|
|
46
|
+
if (config.schemaVersion !== 1 || config.profileVersion !== githubProfileVersion) errors.push('Unsupported GitHub profile version.');
|
|
47
|
+
if (!safeRepo(config.repository)) errors.push('repository must be owner/name on github.com.');
|
|
48
|
+
if (!['collaborators', 'public', 'preserve'].includes(config.intake)) errors.push('Choose intake: collaborators, public or preserve.');
|
|
49
|
+
if (!Number.isInteger(config.requiredApprovals) || config.requiredApprovals < 0 || config.requiredApprovals > 6) errors.push('requiredApprovals must be an explicit integer from 0 to 6.');
|
|
50
|
+
if (!Array.isArray(config.requiredChecks) || !config.requiredChecks.length) errors.push('Declare at least one unconditional required CI check.');
|
|
51
|
+
else {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const check of config.requiredChecks) {
|
|
54
|
+
if (!record(check) || Object.keys(check).some((key) => !['context', 'integrationId'].includes(key)) || typeof check.context !== 'string' || !check.context.trim() || !Number.isInteger(check.integrationId) || check.integrationId <= 0) errors.push('Each required check needs a context and a positive integrationId.');
|
|
55
|
+
else if (seen.has(check.context)) errors.push(`Duplicate required check: ${check.context}.`);
|
|
56
|
+
else seen.add(check.context);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (config.evidenceBranch != null && (typeof config.evidenceBranch !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_/-]*$/.test(config.evidenceBranch) || config.evidenceBranch.includes('//') || config.evidenceBranch.endsWith('/'))) errors.push('evidenceBranch must be a literal branch name, not a pattern.');
|
|
60
|
+
if (config.releaseWorkflowReady !== undefined && typeof config.releaseWorkflowReady !== 'boolean') errors.push('releaseWorkflowReady must be boolean.');
|
|
61
|
+
if (config.overrides !== undefined && !record(config.overrides)) errors.push('overrides must be an object.');
|
|
62
|
+
for (const [id, override] of Object.entries(record(config.overrides) ? config.overrides : {})) {
|
|
63
|
+
if (!Object.hasOwn(defaults, id) && !['rules.main', 'rules.evidence'].includes(id)) { errors.push(`Unknown override: ${id}.`); continue; }
|
|
64
|
+
if (!record(override) || Object.keys(override).some((key) => !['value', 'reason'].includes(key)) || !Object.hasOwn(override, 'value') || typeof override.reason !== 'string' || !override.reason.trim()) { errors.push(`${id} needs a value and a non-empty exception reason.`); continue; }
|
|
65
|
+
if (override.value === null) continue;
|
|
66
|
+
if (id.startsWith('rules.')) { errors.push(`${id} only accepts a null exception; configure protection through checks and approvals.`); continue; }
|
|
67
|
+
const standard = defaults[id];
|
|
68
|
+
if (typeof standard === 'boolean' && typeof override.value !== 'boolean') errors.push(`${id} must be boolean or null.`);
|
|
69
|
+
else if (typeof standard === 'string') {
|
|
70
|
+
const values = id === 'repository.squash_merge_commit_title' ? ['PR_TITLE', 'COMMIT_OR_PR_TITLE']
|
|
71
|
+
: id === 'repository.squash_merge_commit_message' ? ['PR_BODY', 'COMMIT_MESSAGES', 'BLANK']
|
|
72
|
+
: id === 'actions.forkApproval' ? ['all_external_contributors', 'first_time_contributors', 'first_time_contributors_new_to_github'] : ['enabled', 'disabled'];
|
|
73
|
+
if (!values.includes(override.value)) errors.push(`${id} has an unsupported value.`);
|
|
74
|
+
} else if (record(standard) && !equal(override.value, standard)) errors.push(`${id} may only be preserved with null or retain the recommended permissions.`);
|
|
75
|
+
if (id.startsWith('security.') && ['disabled', false].includes(override.value)) errors.push(`${id}: this adapter never disables security; use a reasoned null exception to preserve it.`);
|
|
76
|
+
if (id === 'releases.immutable' && override.value === false) errors.push('This adapter never disables release immutability; use null to preserve it.');
|
|
77
|
+
}
|
|
78
|
+
const desired = { ...defaults, ...Object.fromEntries(Object.entries(record(config.overrides) ? config.overrides : {}).filter(([, value]) => record(value)).map(([id, value]) => [id, value.value])) };
|
|
79
|
+
if (['allow_squash_merge', 'allow_merge_commit', 'allow_rebase_merge'].every((key) => desired[`repository.${key}`] === false)) errors.push('At least one merge method must remain enabled.');
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertConfig(config) {
|
|
84
|
+
const errors = validateGithubProfile(config);
|
|
85
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fix the host explicitly: GH_HOST must not redirect a reviewed github.com plan.
|
|
89
|
+
export function githubRequest(path, method = 'GET', body) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const args = ['api', '--hostname', 'github.com', '--method', method, path];
|
|
92
|
+
if (body !== undefined) args.push('--input', '-');
|
|
93
|
+
const child = execFile('gh', args, { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
94
|
+
if (error) {
|
|
95
|
+
const status = Number((stderr.match(/HTTP (\d+)/) ?? [])[1]) || null;
|
|
96
|
+
reject(Object.assign(new Error(`GitHub ${method} ${path} failed${status ? ` (HTTP ${status})` : ''}. Check authentication, permissions and provider availability.`), { status }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = stdout.trim() ? JSON.parse(stdout) : null;
|
|
101
|
+
if (result?.errors?.length) throw new Error('GitHub GraphQL returned errors; check field availability and permissions.');
|
|
102
|
+
resolve(result);
|
|
103
|
+
} catch (parseError) { reject(parseError); }
|
|
104
|
+
});
|
|
105
|
+
child.stdin.on('error', () => {});
|
|
106
|
+
child.stdin.end(body === undefined ? undefined : JSON.stringify(body));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function capture(read) {
|
|
111
|
+
try { return { available: true, value: await read() }; }
|
|
112
|
+
catch (error) { return { available: false, status: error.status ?? null, reason: error.message }; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readGithubProfile(config, request = githubRequest) {
|
|
116
|
+
assertConfig(config);
|
|
117
|
+
const base = `repos/${config.repository}`;
|
|
118
|
+
const repo = await request(base);
|
|
119
|
+
if (repo.full_name?.toLowerCase() !== config.repository.toLowerCase() || !Number.isInteger(repo.id) || !repo.default_branch || !repo.node_id) throw new Error('GitHub repository identity could not be verified.');
|
|
120
|
+
const [owner, name] = config.repository.split('/');
|
|
121
|
+
const snapshot = {
|
|
122
|
+
identity: { id: repo.id, nodeId: repo.node_id, repo: repo.full_name, defaultBranch: repo.default_branch, visibility: repo.visibility, admin: repo.permissions?.admin === true },
|
|
123
|
+
values: Object.fromEntries(Object.keys(repositoryDefaults).map((key) => [`repository.${key}`, repo[key] === undefined ? { available: false, reason: 'GitHub did not return this setting.' } : { available: true, value: repo[key] }])),
|
|
124
|
+
};
|
|
125
|
+
const jobs = {
|
|
126
|
+
'actions.workflow': () => request(`${base}/actions/permissions/workflow`),
|
|
127
|
+
'actions.forkApproval': async () => (await request(`${base}/actions/permissions/fork-pr-contributor-approval`)).approval_policy,
|
|
128
|
+
'security.alerts': async () => {
|
|
129
|
+
try { await request(`${base}/vulnerability-alerts`); return true; }
|
|
130
|
+
catch (error) { if (error.status === 404 && snapshot.identity.admin) return false; throw error; }
|
|
131
|
+
},
|
|
132
|
+
'security.updates': async () => {
|
|
133
|
+
try { const result = await request(`${base}/automated-security-fixes`); return result.enabled === true && result.paused !== true; }
|
|
134
|
+
catch (error) { if (error.status === 404 && snapshot.identity.admin) return false; throw error; }
|
|
135
|
+
},
|
|
136
|
+
'releases.immutable': async () => {
|
|
137
|
+
try { return (await request(`${base}/immutable-releases`)).enabled === true; }
|
|
138
|
+
catch (error) { if (error.status === 404 && snapshot.identity.admin) return false; throw error; }
|
|
139
|
+
},
|
|
140
|
+
intake: async () => {
|
|
141
|
+
const result = await request('graphql', 'POST', { query: 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){issueCreationPolicy pullRequestCreationPolicy}}', variables: { owner, name } });
|
|
142
|
+
const value = result?.data?.repository;
|
|
143
|
+
if (!value?.issueCreationPolicy || !value?.pullRequestCreationPolicy) throw new Error('GitHub intake policies are unavailable.');
|
|
144
|
+
return value;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
for (const name of ['secret_scanning', 'secret_scanning_push_protection']) {
|
|
148
|
+
snapshot.values[`security.${name}`] = repo.security_and_analysis?.[name]?.status
|
|
149
|
+
? { available: true, value: repo.security_and_analysis[name].status }
|
|
150
|
+
: { available: false, reason: 'Security setting unavailable for this repository, plan or credential.' };
|
|
151
|
+
}
|
|
152
|
+
for (const [id, branch] of [['rules.main', repo.default_branch], ...(config.evidenceBranch ? [['rules.evidence', config.evidenceBranch]] : [])]) {
|
|
153
|
+
jobs[id] = async () => {
|
|
154
|
+
const rules = [];
|
|
155
|
+
for (let page = 1; ; page++) {
|
|
156
|
+
const batch = await request(`${base}/rules/branches/${encodeURIComponent(branch)}?per_page=100&page=${page}`);
|
|
157
|
+
if (!Array.isArray(batch)) throw new Error('GitHub did not return effective rules.');
|
|
158
|
+
rules.push(...batch);
|
|
159
|
+
if (batch.length < 100) break;
|
|
160
|
+
}
|
|
161
|
+
const ids = [...new Set(rules.map((rule) => `${rule.ruleset_source_type}:${rule.ruleset_source}:${rule.ruleset_id}`))];
|
|
162
|
+
const policies = {};
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const [type, source, number] = id.split(':');
|
|
165
|
+
const endpoint = type === 'Repository' ? `repos/${source}/rulesets/${number}` : type === 'Organization' ? `orgs/${source}/rulesets/${number}` : null;
|
|
166
|
+
if (!endpoint) throw new Error('Cannot establish bypass policy for an inherited ruleset.');
|
|
167
|
+
policies[id] = await request(endpoint);
|
|
168
|
+
}
|
|
169
|
+
return rules.map((rule) => {
|
|
170
|
+
const policy = policies[`${rule.ruleset_source_type}:${rule.ruleset_source}:${rule.ruleset_id}`];
|
|
171
|
+
return { type: rule.type, parameters: rule.parameters ?? {}, id: rule.ruleset_id, source: rule.ruleset_source, enforcement: policy.enforcement, bypass: policy.bypass_actors ?? null };
|
|
172
|
+
}).sort((a, b) => canonical(a).localeCompare(canonical(b)));
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const entries = Object.entries(jobs);
|
|
176
|
+
const results = await Promise.all(entries.map(([, read]) => capture(read)));
|
|
177
|
+
entries.forEach(([id], index) => { snapshot.values[id] = results[index]; });
|
|
178
|
+
return snapshot;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function requiredRules(config, main) {
|
|
182
|
+
const rules = [{ type: 'deletion' }, { type: 'non_fast_forward' }];
|
|
183
|
+
if (main) rules.push(
|
|
184
|
+
{ type: 'pull_request', parameters: { required_approving_review_count: config.requiredApprovals, dismiss_stale_reviews_on_push: true, require_code_owner_review: false, require_last_push_approval: false, required_review_thread_resolution: true } },
|
|
185
|
+
{ type: 'required_status_checks', parameters: { strict_required_status_checks_policy: true, required_status_checks: config.requiredChecks.map((check) => ({ context: check.context, integration_id: check.integrationId })) } },
|
|
186
|
+
);
|
|
187
|
+
return rules;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ruleSatisfied(expected, observed) {
|
|
191
|
+
const candidates = observed.filter((rule) => rule.type === expected.type && rule.enforcement === 'active' && Array.isArray(rule.bypass) && rule.bypass.length === 0);
|
|
192
|
+
if (expected.type === 'pull_request') return candidates.some(({ parameters: p }) => p.required_approving_review_count >= expected.parameters.required_approving_review_count && p.required_review_thread_resolution === true && p.dismiss_stale_reviews_on_push === true);
|
|
193
|
+
if (expected.type === 'required_status_checks') return expected.parameters.required_status_checks.every((check) => candidates.some(({ parameters: p }) => p.strict_required_status_checks_policy === true && p.required_status_checks?.some((item) => item.context === check.context && item.integration_id === check.integration_id)));
|
|
194
|
+
return candidates.length > 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function buildGithubProfilePlan(config, snapshot) {
|
|
198
|
+
assertConfig(config);
|
|
199
|
+
if (snapshot.identity.repo.toLowerCase() !== config.repository.toLowerCase()) throw new Error('Profile and provider repository do not match.');
|
|
200
|
+
if (config.evidenceBranch === snapshot.identity.defaultBranch) throw new Error('The evidence branch must be separate from the default branch.');
|
|
201
|
+
const desired = { ...defaults, intake: config.intake === 'preserve' ? null : { issueCreationPolicy: config.intake === 'public' ? 'ALL' : 'COLLABORATORS_ONLY', pullRequestCreationPolicy: config.intake === 'public' ? 'ALL' : 'COLLABORATORS_ONLY' }, 'rules.main': requiredRules(config, true) };
|
|
202
|
+
if (config.evidenceBranch) desired['rules.evidence'] = requiredRules(config, false);
|
|
203
|
+
const controls = Object.entries(desired).map(([id, standard]) => {
|
|
204
|
+
const override = config.overrides?.[id];
|
|
205
|
+
const expected = override ? override.value : standard;
|
|
206
|
+
const observed = snapshot.values[id] ?? { available: false, reason: 'Missing provider observation.' };
|
|
207
|
+
let status = expected === null ? 'excepted' : !observed.available ? 'unavailable'
|
|
208
|
+
: id.startsWith('rules.') ? expected.every((rule) => ruleSatisfied(rule, observed.value)) ? 'passed' : 'drift'
|
|
209
|
+
: equal(expected, observed.value) ? 'passed' : 'drift';
|
|
210
|
+
let reason = override?.reason ?? (id === 'intake' && config.intake === 'preserve' ? 'The adopter chose to preserve contribution policy.' : observed.reason ?? null);
|
|
211
|
+
if (id === 'actions.forkApproval' && snapshot.identity.visibility !== 'public' && expected !== null) { status = 'not-applicable'; reason = 'Public fork approval policy does not apply to this private repository; private-fork policy requires separate review.'; }
|
|
212
|
+
if (id === 'releases.immutable' && status === 'drift' && config.releaseWorkflowReady !== true) { status = 'blocked'; reason = 'Confirm draft -> attach all assets -> publish with releaseWorkflowReady, or preserve this setting with a reasoned exception.'; }
|
|
213
|
+
return { id, recommended: standard, expected, observed, status, reason, exception: Boolean(override) };
|
|
214
|
+
});
|
|
215
|
+
const blockers = controls.filter((control) => ['blocked', 'unavailable'].includes(control.status));
|
|
216
|
+
const changes = controls.filter((control) => control.status === 'drift').map((control) => control.id);
|
|
217
|
+
const canApply = blockers.length === 0 && (changes.length === 0 || snapshot.identity.admin);
|
|
218
|
+
const plan = { schemaVersion: 1, kind: 'github-profile', profileVersion: githubProfileVersion, repository: snapshot.identity, configuration: config, controls, changes, canApply, status: blockers.length ? 'blocked' : changes.length ? 'drift' : 'passed', authorization: { planHash: '' } };
|
|
219
|
+
plan.authorization.planHash = githubProfilePlanHash(plan);
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function githubProfilePlanHash(plan) {
|
|
224
|
+
const { authorization, ...payload } = plan;
|
|
225
|
+
return digest(payload);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function validatePlan(plan, authorization) {
|
|
229
|
+
if (!plan || plan.kind !== 'github-profile' || plan.profileVersion !== githubProfileVersion || plan.schemaVersion !== 1) throw new Error('Unsupported GitHub profile plan.');
|
|
230
|
+
assertConfig(plan.configuration);
|
|
231
|
+
if (authorization !== plan.authorization?.planHash || authorization !== githubProfilePlanHash(plan)) throw new Error('GitHub profile plan authorization does not match; generate a new plan.');
|
|
232
|
+
const rebuilt = buildGithubProfilePlan(plan.configuration, { identity: plan.repository, values: Object.fromEntries(plan.controls.map((control) => [control.id, control.observed])) });
|
|
233
|
+
if (!equal(rebuilt, plan)) throw new Error('GitHub profile plan is not a valid projection of its configuration and observations.');
|
|
234
|
+
if (!plan.canApply) throw new Error('GitHub profile plan is blocked. Resolve unavailable controls or record explicit exceptions.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function writeControl(control, plan, request) {
|
|
238
|
+
const base = `repos/${plan.configuration.repository}`;
|
|
239
|
+
const id = control.id;
|
|
240
|
+
if (id.startsWith('repository.')) {
|
|
241
|
+
const body = { [id.slice(11)]: control.expected };
|
|
242
|
+
if (id === 'repository.squash_merge_commit_message') {
|
|
243
|
+
const title = plan.controls.find((item) => item.id === 'repository.squash_merge_commit_title');
|
|
244
|
+
body.squash_merge_commit_title = title.expected ?? title.observed.value;
|
|
245
|
+
if (!body.squash_merge_commit_title) throw new Error('Cannot preserve the unobserved squash title setting.');
|
|
246
|
+
}
|
|
247
|
+
return request(base, 'PATCH', body);
|
|
248
|
+
}
|
|
249
|
+
if (id === 'actions.workflow') return request(`${base}/actions/permissions/workflow`, 'PUT', control.expected);
|
|
250
|
+
if (id === 'actions.forkApproval') return request(`${base}/actions/permissions/fork-pr-contributor-approval`, 'PUT', { approval_policy: control.expected });
|
|
251
|
+
if (id === 'security.alerts') return request(`${base}/vulnerability-alerts`, 'PUT');
|
|
252
|
+
if (id === 'security.updates') return request(`${base}/automated-security-fixes`, 'PUT');
|
|
253
|
+
if (id.startsWith('security.')) return request(base, 'PATCH', { security_and_analysis: { [id.slice(9)]: { status: control.expected } } });
|
|
254
|
+
if (id === 'releases.immutable') return request(`${base}/immutable-releases`, 'PUT');
|
|
255
|
+
if (id === 'intake') return request('graphql', 'POST', { query: 'mutation($input:UpdateRepositoryInput!){updateRepository(input:$input){repository{id issueCreationPolicy pullRequestCreationPolicy}}}', variables: { input: { repositoryId: plan.repository.nodeId, ...control.expected } } });
|
|
256
|
+
if (id.startsWith('rules.')) {
|
|
257
|
+
const main = id === 'rules.main';
|
|
258
|
+
return request(`${base}/rulesets`, 'POST', { name: `Lighthouse recommended ${main ? 'main' : 'evidence'} v${githubProfileVersion}`, target: 'branch', enforcement: 'active', bypass_actors: [], conditions: { ref_name: { include: [main ? '~DEFAULT_BRANCH' : `refs/heads/${plan.configuration.evidenceBranch}`], exclude: [] } }, rules: control.expected });
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`Unsupported control: ${id}.`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function checkFresh(plan, current) {
|
|
264
|
+
if (!equal(plan.repository, current.repository)) throw new Error('Repository identity, visibility, default branch or administration permission changed; generate a new plan.');
|
|
265
|
+
for (const control of current.controls) {
|
|
266
|
+
const original = plan.controls.find((item) => item.id === control.id);
|
|
267
|
+
if (control.status === 'passed' || ['excepted', 'not-applicable'].includes(control.status)) continue;
|
|
268
|
+
if (!original || original.status !== 'drift' || !equal(control.observed, original.observed)) throw new Error(`GitHub ${control.id} changed outside the authorized plan; generate a new plan.`);
|
|
269
|
+
}
|
|
270
|
+
if (!current.canApply) throw new Error('GitHub profile is no longer applicable.');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function applyGithubProfile(plan, authorization, { request = githubRequest } = {}) {
|
|
274
|
+
validatePlan(plan, authorization);
|
|
275
|
+
const completed = [];
|
|
276
|
+
const receipt = (status, extra = {}) => ({ schemaVersion: 1, kind: 'github-profile-receipt', profileVersion: githubProfileVersion, repository: plan.repository, planHash: authorization, configurationHash: digest(plan.configuration), checkedAt: new Date().toISOString(), completed: [...completed], status, ...extra });
|
|
277
|
+
try {
|
|
278
|
+
let current = buildGithubProfilePlan(plan.configuration, await readGithubProfile(plan.configuration, request));
|
|
279
|
+
checkFresh(plan, current);
|
|
280
|
+
// Add protection first; then settings. Never PUT or delete another ruleset.
|
|
281
|
+
const priority = (id) => id.startsWith('rules.') ? 0 : /^repository.allow_(squash_merge|merge_commit|rebase_merge)$/.test(id) && plan.controls.find((item) => item.id === id).expected === true ? 1 : 2;
|
|
282
|
+
const ids = [...plan.changes].sort((a, b) => priority(a) - priority(b));
|
|
283
|
+
for (const id of ids) {
|
|
284
|
+
current = buildGithubProfilePlan(plan.configuration, await readGithubProfile(plan.configuration, request));
|
|
285
|
+
checkFresh(plan, current);
|
|
286
|
+
const control = current.controls.find((item) => item.id === id);
|
|
287
|
+
if (control.status === 'passed') continue;
|
|
288
|
+
await writeControl(control, current, request);
|
|
289
|
+
completed.push(id);
|
|
290
|
+
}
|
|
291
|
+
const final = buildGithubProfilePlan(plan.configuration, await readGithubProfile(plan.configuration, request));
|
|
292
|
+
if (final.status !== 'passed') throw new Error('GitHub readback has not reached the declared state; verify before retrying.');
|
|
293
|
+
return receipt('passed', { controls: final.controls, exceptions: final.controls.filter((control) => control.exception || control.status === 'excepted') });
|
|
294
|
+
} catch (error) {
|
|
295
|
+
error.result = receipt('failed', { error: error.message });
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const githubProfileManualRecommendations = Object.freeze([
|
|
301
|
+
'Keep Wiki deferred or explicitly selected, with editing restricted to collaborators.',
|
|
302
|
+
'Review issue forms, sponsorship, Projects, social preview and archive participation for the product.',
|
|
303
|
+
'Review commit comments, linked-issue auto-close, LFS archives and multi-ref push limits in GitHub settings; this adapter does not manage them.',
|
|
304
|
+
'Review Actions retention, workflow-specific token permissions, self-hosted admission and integration authority separately; repository defaults do not enforce those controls.',
|
|
305
|
+
]);
|