Add governance config validation
All checks were successful
ci / test (pull_request) Successful in 24s
All checks were successful
ci / test (pull_request) Successful in 24s
This commit is contained in:
parent
4c6185898e
commit
e86ce95180
3 changed files with 186 additions and 0 deletions
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
panel=codex-bot-andresmgsl glm-bot-andresmgsl cluade-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
|
triage-actors=cluade-bot-andresmgsl
|
||||||
|
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
||||||
|
scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path
|
||||||
|
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
||||||
|
scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates
|
||||||
|
scope:docs|C5DEF5|README and docs/ — the prose contract
|
||||||
82
scripts/check-governance.js
Normal file
82
scripts/check-governance.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
config: path.join(process.cwd(), '.github', 'labels.conf'),
|
||||||
|
apiUrl: process.env.FORGE_API_URL
|
||||||
|
|| `${(process.env.FORGE_SERVER_URL || 'https://forgejo.heavyduty.builders').replace(/\/$/, '')}/api/v1`,
|
||||||
|
};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const flag = argv[index];
|
||||||
|
if (flag !== '--config' && flag !== '--api-url') {
|
||||||
|
throw new Error(`unknown option: ${flag}`);
|
||||||
|
}
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!value) throw new Error(`${flag} requires a value`);
|
||||||
|
options[flag === '--config' ? 'config' : 'apiUrl'] = value;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfig(contents, filename) {
|
||||||
|
let panel;
|
||||||
|
let triageActors = [];
|
||||||
|
const scopes = [];
|
||||||
|
for (const line of contents.split(/\r?\n/)) {
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('panel=')) {
|
||||||
|
if (panel) throw new Error(`duplicate panel line in ${filename}`);
|
||||||
|
panel = line.slice('panel='.length).trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (panel.length === 0) throw new Error(`panel must name at least one reviewer in ${filename}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('triage-actors=')) {
|
||||||
|
triageActors = line.slice('triage-actors='.length).trim().split(/\s+/).filter(Boolean);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fields = line.split('|');
|
||||||
|
if (fields.length !== 3 || !fields[0] || !/^[0-9A-Fa-f]{6}$/.test(fields[1]) || !fields[2]) {
|
||||||
|
throw new Error(`malformed label row: ${line} in ${filename}`);
|
||||||
|
}
|
||||||
|
scopes.push(line);
|
||||||
|
}
|
||||||
|
if (!panel) throw new Error(`missing panel= line in ${filename}`);
|
||||||
|
|
||||||
|
const identities = [...new Set([...panel, ...triageActors])];
|
||||||
|
for (const login of identities) {
|
||||||
|
if (!/^[A-Za-z0-9-]+$/.test(login)) {
|
||||||
|
throw new Error(`malformed login in ${filename}: ${login}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { identities, scopes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateIdentities(apiUrl, identities) {
|
||||||
|
for (const login of identities) {
|
||||||
|
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${login}: fetch failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (response.status !== 200) throw new Error(`${login}: HTTP ${response.status} from ${endpoint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const contents = fs.readFileSync(options.config, 'utf8');
|
||||||
|
const { identities, scopes } = parseConfig(contents, options.config);
|
||||||
|
await validateIdentities(options.apiUrl, identities);
|
||||||
|
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`governance: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
97
test/governance.test.js
Normal file
97
test/governance.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-governance.js');
|
||||||
|
const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
|
||||||
|
const cleanups = [];
|
||||||
|
process.on('exit', () => {
|
||||||
|
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeConfig(contents) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-test-'));
|
||||||
|
cleanups.push(dir);
|
||||||
|
const config = path.join(dir, 'labels.conf');
|
||||||
|
fs.writeFileSync(config, contents);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runValidator(config, apiUrl) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||||
|
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withIdentityServer(logins, callback) {
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const login = decodeURIComponent(request.url.replace('/api/v1/users/', ''));
|
||||||
|
response.writeHead(logins.has(login) ? 200 : 404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(logins.has(login) ? { login } : { message: 'not found' }));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
try {
|
||||||
|
await callback(`http://127.0.0.1:${port}/api/v1`);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validConfig = [
|
||||||
|
'panel=codex-bot-andresmgsl glm-bot-andresmgsl cluade-bot-andresmgsl kimi-bot-andresmgsl',
|
||||||
|
'triage-actors=cluade-bot-andresmgsl',
|
||||||
|
'scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)',
|
||||||
|
'scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path',
|
||||||
|
'scope:manifests|C5DEF5|manifests/ — the fleet repo registry data',
|
||||||
|
'scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates',
|
||||||
|
'scope:docs|C5DEF5|README and docs/ — the prose contract',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
test('governance validator accepts the configured roster when every identity resolves', async () => {
|
||||||
|
const config = writeConfig(`${validConfig}\n`);
|
||||||
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||||
|
await withIdentityServer(logins, async (apiUrl) => {
|
||||||
|
const result = await runValidator(config, apiUrl);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('governance validator fails when a roster identity does not resolve', async () => {
|
||||||
|
const config = writeConfig(`${validConfig.replace('kimi-bot-andresmgsl', 'kimi-bto-andresmgsl')}\n`);
|
||||||
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||||
|
await withIdentityServer(logins, async (apiUrl) => {
|
||||||
|
const result = await runValidator(config, apiUrl);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /kimi-bto-andresmgsl.*HTTP 404/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('governance validator rejects malformed scope rows before identity requests', async () => {
|
||||||
|
const config = writeConfig(`${validConfig.replace('|C5DEF5|', '|not-a-color|')}\n`);
|
||||||
|
const result = await runValidator(config, 'http://127.0.0.1:1/api/v1');
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /malformed label row/);
|
||||||
|
assert.doesNotMatch(result.stderr, /fetch failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository governance config resolves the four-member panel and five scopes', async () => {
|
||||||
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'cluade-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||||
|
await withIdentityServer(logins, async (apiUrl) => {
|
||||||
|
const result = await runValidator(REPOSITORY_CONFIG, apiUrl);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue