Repository navigation
Expand file tree
/
Copy pathsync-plugin-standards.mjs
More file actions
288 lines (260 loc) · 14.4 KB
/
Copy pathsync-plugin-standards.mjs
File metadata and controls
288 lines (260 loc) · 14.4 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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
#!/usr/bin/env node
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateCompatibilityBaseline } from './compatibility-policy.mjs';
import { validateProjectChecks } from './project-check-policy.mjs';
import { validateIntegrationPolicy } from './integration-check-policy.mjs';
const managedMarker = '# Managed by stuttter/.github fleet standards. Do not edit locally.';
const managedSkillPath = '.github/skills/code-review/SKILL.md';
const scriptRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
function isUriReference(value) {
if (typeof value !== 'string') return false;
try {
new URL(value, 'https://schema.invalid/');
return true;
} catch {
return false;
}
}
export function validateManifest(manifest, context = 'manifest') {
const required = ['slug', 'main_file', 'risk', 'minimum_php', 'minimum_wordpress', 'tested_wordpress', 'wordpress_org', 'multisite'];
const optional = ['$schema', 'release_branch', 'php_matrix'];
const errors = [];
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return [`${context} must be an object.`];
}
for (const key of required) {
if (!(key in manifest)) errors.push(`${context} is missing ${key}.`);
}
for (const key of Object.keys(manifest)) {
if (![...required, ...optional].includes(key)) errors.push(`${context} has unsupported key ${key}.`);
}
if (typeof manifest.slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.slug)) errors.push(`${context} slug is invalid.`);
if ('$schema' in manifest && !isUriReference(manifest.$schema)) errors.push(`${context} $schema must be a URI reference.`);
if (typeof manifest.main_file !== 'string' || !/^[^/]+\.php$/.test(manifest.main_file)) errors.push(`${context} main_file is invalid.`);
if (!['standard', 'elevated', 'critical'].includes(manifest.risk)) errors.push(`${context} risk is invalid.`);
for (const key of ['minimum_php', 'minimum_wordpress', 'tested_wordpress']) {
if (typeof manifest[key] !== 'string' || !/^\d+\.\d+$/.test(manifest[key])) errors.push(`${context} ${key} is invalid.`);
}
for (const key of ['wordpress_org', 'multisite']) {
if (typeof manifest[key] !== 'boolean') errors.push(`${context} ${key} must be boolean.`);
}
if ('release_branch' in manifest && (typeof manifest.release_branch !== 'string' || !/^[A-Za-z0-9._/-]+$/.test(manifest.release_branch))) errors.push(`${context} release_branch is invalid.`);
if ('php_matrix' in manifest) {
if (!Array.isArray(manifest.php_matrix) || manifest.php_matrix.length === 0 || new Set(manifest.php_matrix).size !== manifest.php_matrix.length || manifest.php_matrix.some((version) => typeof version !== 'string' || !/^\d+\.\d+$/.test(version))) {
errors.push(`${context} php_matrix is invalid.`);
} else {
const sorted = [...manifest.php_matrix].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
if (!manifest.php_matrix.includes(manifest.minimum_php)) errors.push(`${context} php_matrix must include minimum_php.`);
if (manifest.php_matrix[0] !== manifest.minimum_php) errors.push(`${context} php_matrix must begin with minimum_php.`);
if (JSON.stringify(sorted) !== JSON.stringify(manifest.php_matrix)) errors.push(`${context} php_matrix must be ordered from oldest to newest.`);
}
}
return errors;
}
export function loadInventory(path) {
const inventory = JSON.parse(readFileSync(path, 'utf8'));
const errors = [];
const allowedRoot = new Set(['$schema', 'repositories']);
if (!inventory || typeof inventory !== 'object' || Array.isArray(inventory)) throw new Error('Portfolio inventory must be an object.');
for (const key of Object.keys(inventory)) if (!allowedRoot.has(key)) errors.push(`Inventory has unsupported key ${key}.`);
if ('$schema' in inventory && !isUriReference(inventory.$schema)) errors.push('Inventory $schema must be a URI reference.');
if (!Array.isArray(inventory.repositories)) errors.push('Inventory repositories must be an array.');
const seen = new Set();
for (const [index, item] of (inventory.repositories || []).entries()) {
const context = `repositories[${index}]`;
if (!item || typeof item !== 'object' || Array.isArray(item)) {
errors.push(`${context} must be an object.`);
continue;
}
for (const key of Object.keys(item)) if (!['repository', 'enabled', 'managed_paths', 'manifest', 'checks', 'integration', 'protection'].includes(key)) errors.push(`${context} has unsupported key ${key}.`);
if (typeof item.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(item.repository)) errors.push(`${context} repository is invalid.`);
const repositoryIdentity = typeof item.repository === 'string' ? item.repository.toLowerCase() : item.repository;
if (seen.has(repositoryIdentity)) errors.push(`${context} duplicates ${item.repository}.`);
seen.add(repositoryIdentity);
if (typeof item.enabled !== 'boolean') errors.push(`${context} enabled must be boolean.`);
const managedPaths = ['ci', 'release', 'dependabot'];
if (!Array.isArray(item.managed_paths) || new Set(item.managed_paths).size !== item.managed_paths.length || item.managed_paths.some((path) => !managedPaths.includes(path))) {
errors.push(`${context} managed_paths is invalid.`);
}
if (item.managed_paths?.includes('release') && item.manifest?.wordpress_org !== true) errors.push(`${context} cannot manage a WordPress.org release caller when wordpress_org is false.`);
errors.push(...validateIntegrationPolicy(item.integration, `${context}.integration`));
errors.push(...validateManifest(item.manifest, `${context}.manifest`));
errors.push(...validateCompatibilityBaseline(item, context));
errors.push(...validateProjectChecks(item.checks, `${context}.checks`, item.manifest?.multisite === true));
if ('protection' in item) {
if (!item.protection || typeof item.protection !== 'object' || Array.isArray(item.protection)) {
errors.push(`${context}.protection must be an object when declared.`);
} else {
for (const key of Object.keys(item.protection)) if (key !== 'extra_required_checks') errors.push(`${context}.protection has unsupported key ${key}.`);
const extra = item.protection.extra_required_checks;
if (!Array.isArray(extra) || new Set(extra).size !== extra.length || extra.some((check) => typeof check !== 'string' || check.length === 0 || /[\r\n]/u.test(check))) {
errors.push(`${context}.protection.extra_required_checks is invalid.`);
}
}
}
}
if (errors.length) throw new Error(errors.join('\n'));
return inventory;
}
function render(template, values) {
return template.replace(/{{([a-z_]+)}}/g, (_match, key) => {
if (!(key in values)) throw new Error(`Template variable ${key} is not defined.`);
return values[key];
});
}
function comparableManifest(manifest) {
return Object.fromEntries(Object.entries(manifest).filter(([key]) => key !== '$schema').sort(([left], [right]) => left.localeCompare(right)));
}
function safeManagedPath(root, relativePath) {
const canonicalRoot = realpathSync(root);
const path = resolve(canonicalRoot, relativePath);
const fromRoot = relative(canonicalRoot, path);
if (fromRoot === '' || fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(fromRoot)) {
return { path, reason: 'managed path resolves outside the repository root' };
}
let current = canonicalRoot;
for (const component of fromRoot.split(/[\\/]/)) {
current = join(current, component);
try {
const status = lstatSync(current);
if (status.isSymbolicLink()) {
return { path, reason: `managed path contains symbolic link: ${relative(canonicalRoot, current)}` };
}
if (current !== path && !status.isDirectory()) {
return { path, reason: `managed path parent is not a directory: ${relative(canonicalRoot, current)}` };
}
if (current === path && !status.isFile()) {
return { path, reason: 'managed path exists but is not a regular file' };
}
} catch (error) {
if (error.code === 'ENOENT') break;
throw error;
}
}
return { path, reason: null };
}
function writeManagedFile(root, relativePath, desired) {
const initial = safeManagedPath(root, relativePath);
if (initial.reason) throw new Error(`${relativePath}: ${initial.reason}.`);
const parent = dirname(initial.path);
mkdirSync(parent, { recursive: true });
const verified = safeManagedPath(root, relativePath);
if (verified.reason) {
throw new Error(`${relativePath}: ${verified.reason}.`);
}
const temporaryDirectory = mkdtempSync(join(parent, '.fleet-sync-'));
const temporaryPath = join(temporaryDirectory, 'managed-file');
try {
writeFileSync(temporaryPath, desired, { flag: 'wx' });
renameSync(temporaryPath, verified.path);
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
export function desiredFiles(root, target, policyRef) {
const { manifest } = target;
const releaseBranch = manifest.release_branch || 'main';
const phpMatrix = manifest.php_matrix || [manifest.minimum_php];
const values = {
policy_ref: policyRef,
release_branch: releaseBranch,
php_matrix_json: JSON.stringify(phpMatrix),
quality_php_version: [...phpMatrix].sort((left, right) => left.localeCompare(right, undefined, { numeric: true })).at(-1),
};
const template = (name) => readFileSync(resolve(scriptRoot, 'fleet/templates', name), 'utf8');
const files = new Map([
['.github/plugin-standard.json', `${JSON.stringify({ $schema: 'https://raw.githubusercontent.com/stuttter/.github/main/schema/plugin-standard.schema.json', ...manifest }, null, 2)}\n`],
]);
if (target.enabled) files.set(managedSkillPath, readFileSync(resolve(scriptRoot, managedSkillPath), 'utf8'));
const managed = new Set(target.managed_paths);
if (managed.has('ci')) files.set('.github/workflows/ci.yml', render(template('ci.yml'), values));
if (managed.has('release')) files.set('.github/workflows/release.yml', render(template('release.yml'), values));
if (managed.has('dependabot')) {
const hasComposer = existsSync(resolve(root, 'composer.json'));
const hasNpm = existsSync(resolve(root, 'package.json'));
const suffix = hasComposer && hasNpm ? '-composer-npm' : hasComposer ? '-composer' : hasNpm ? '-npm' : '';
files.set('.github/dependabot.yml', template(`dependabot${suffix}.yml`));
}
return files;
}
export function synchronize({ root, target, policyRef, mode = 'audit' }) {
if (!['audit', 'apply'].includes(mode)) throw new Error(`Unsupported mode: ${mode}.`);
if (typeof policyRef !== 'string' || !/^[0-9a-f]{40}$/.test(policyRef)) throw new Error('policyRef must be the full commit SHA of the fleet policy checkout.');
const changes = [];
const conflicts = [];
const writes = [];
for (const [relativePath, desired] of desiredFiles(root, target, policyRef)) {
const safety = safeManagedPath(root, relativePath);
const { path } = safety;
if (safety.reason) {
conflicts.push({ path: relativePath, reason: safety.reason });
continue;
}
if (!existsSync(path)) {
changes.push({ path: relativePath, action: 'add' });
writes.push({ relativePath, desired });
continue;
}
const current = readFileSync(path, 'utf8');
if (relativePath === '.github/plugin-standard.json') {
let actual;
try {
actual = JSON.parse(current);
} catch (error) {
conflicts.push({ path: relativePath, reason: `invalid JSON: ${error.message}` });
continue;
}
const errors = validateManifest(actual, relativePath);
if (errors.length) {
conflicts.push({ path: relativePath, reason: errors.join(' ') });
} else if (JSON.stringify(comparableManifest(actual)) !== JSON.stringify(comparableManifest(target.manifest))) {
conflicts.push({ path: relativePath, reason: 'repository manifest differs from the portfolio inventory; reconcile it deliberately' });
}
continue;
}
if (current === desired) continue;
const hasManagedMarker = relativePath === managedSkillPath
? current.startsWith(`---\n${managedMarker}\n`)
: current.startsWith(`${managedMarker}\n`);
if (!hasManagedMarker) {
conflicts.push({ path: relativePath, reason: 'existing repository-owned file has no fleet-managed marker' });
continue;
}
changes.push({ path: relativePath, action: 'update' });
writes.push({ relativePath, desired });
}
if (mode === 'apply' && conflicts.length === 0) {
for (const { relativePath, desired } of writes) {
writeManagedFile(root, relativePath, desired);
}
}
return { repository: target.repository, mode, changes, conflicts, clean: changes.length === 0 && conflicts.length === 0 };
}
function parseArguments(argv) {
const options = { mode: 'audit', inventory: resolve(scriptRoot, 'portfolio/plugins.json') };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!['--mode', '--inventory', '--repository', '--repo-dir', '--policy-ref'].includes(argument)) throw new Error(`Unknown argument: ${argument}.`);
if (!argv[index + 1]) throw new Error(`${argument} requires a value.`);
options[argument.slice(2).replace('-', '_')] = argv[index + 1];
index += 1;
}
if (!options.repository || !options.repo_dir || !options.policy_ref) throw new Error('--repository, --repo-dir, and --policy-ref are required.');
return options;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const options = parseArguments(process.argv.slice(2));
const inventory = loadInventory(resolve(options.inventory));
const target = inventory.repositories.find((item) => item.repository === options.repository && item.enabled);
if (!target) throw new Error(`${options.repository} is not an enabled portfolio target.`);
const result = synchronize({ root: resolve(options.repo_dir), target, policyRef: options.policy_ref, mode: options.mode });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
process.exitCode = result.conflicts.length ? 2 : options.mode === 'audit' && result.changes.length ? 1 : 0;
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 2;
}
}