forked from heavy-duty/stoke
269 lines
12 KiB
JavaScript
269 lines
12 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const { execFileSync } = 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 REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
|
|
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
|
|
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
|
|
const PACKAGE_MANIFEST = path.join(__dirname, '..', 'package.json');
|
|
const PACKAGE_LOCK = path.join(__dirname, '..', 'package-lock.json');
|
|
const CEREMONY_REPOSITORY = 'https://forgejo.heavyduty.builders/heavy-duty/ceremony';
|
|
const CEREMONY_VERSION = '0.6.3';
|
|
const CEREMONY_WORKFLOWS = ['labels.yml', 'labels-sweep.yml'];
|
|
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);
|
|
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
|
|
return config;
|
|
}
|
|
|
|
function writeRepository(configContents, labelerContents, files = {}) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-'));
|
|
cleanups.push(dir);
|
|
fs.mkdirSync(path.join(dir, '.github'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents);
|
|
fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents);
|
|
for (const [filename, contents] of Object.entries(files)) {
|
|
const target = path.join(dir, filename);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, contents);
|
|
}
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
execFileSync('git', ['add', '.'], { cwd: dir });
|
|
return { dir, config: path.join(dir, '.github', 'labels.conf') };
|
|
}
|
|
|
|
function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
|
cwd,
|
|
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, redirects = new Map()) {
|
|
const server = http.createServer((request, response) => {
|
|
const login = decodeURIComponent(request.url.replace('/api/v1/users/', ''));
|
|
if (redirects.has(login)) {
|
|
response.writeHead(307, { location: `/api/v1/users/${redirects.get(login)}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
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 claude-bot-andresmgsl kimi-bot-andresmgsl',
|
|
'triage-actors=claude-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', 'claude-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', 'claude-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 a renamed identity that redirects to a live login', async () => {
|
|
const config = writeConfig(`${validConfig.replaceAll('claude-bot-andresmgsl', 'cluade-bot-andresmgsl')}\n`);
|
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
|
const redirects = new Map([['cluade-bot-andresmgsl', 'claude-bot-andresmgsl']]);
|
|
await withIdentityServer(logins, async (apiUrl) => {
|
|
const result = await runValidator(config, apiUrl);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /cluade-bot-andresmgsl.*HTTP 307/);
|
|
}, redirects);
|
|
});
|
|
|
|
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('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => {
|
|
const config = [
|
|
'panel=codex-bot-andresmgsl',
|
|
'scope:cli|C5DEF5|src/ — command surface',
|
|
'scope:ci|C5DEF5|.github/ — governance surface',
|
|
].join('\n');
|
|
const labeler = [
|
|
'"scope:cli":',
|
|
' - changed-files:',
|
|
' - any-glob-to-any-file: ["src/**"]',
|
|
'"scope:ci":',
|
|
' - changed-files:',
|
|
' - any-glob-to-any-file: [".github/**"]',
|
|
].join('\n');
|
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, {
|
|
'src/covered.js': '',
|
|
'new-surface/uncovered.txt': '',
|
|
});
|
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/);
|
|
});
|
|
});
|
|
|
|
test('governance validator rejects scope names declared only in the labeler map', async () => {
|
|
const config = [
|
|
'panel=codex-bot-andresmgsl',
|
|
'scope:cli|C5DEF5|src/ — command surface',
|
|
].join('\n');
|
|
const labeler = [
|
|
'"scope:cli":',
|
|
' - changed-files:',
|
|
' - any-glob-to-any-file: ["src/**"]',
|
|
'"scope:extra":',
|
|
' - changed-files:',
|
|
' - any-glob-to-any-file: ["extra/**"]',
|
|
].join('\n');
|
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/);
|
|
});
|
|
});
|
|
|
|
test('governance validator rejects scope names declared only in labels.conf', async () => {
|
|
const config = [
|
|
'panel=codex-bot-andresmgsl',
|
|
'scope:cli|C5DEF5|src/ — command surface',
|
|
'scope:renamed|C5DEF5|renamed/ — renamed surface',
|
|
].join('\n');
|
|
const labeler = [
|
|
'"scope:cli":',
|
|
' - changed-files:',
|
|
' - any-glob-to-any-file: ["src/**"]',
|
|
].join('\n');
|
|
const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' });
|
|
await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => {
|
|
const result = await runValidator(repository.config, apiUrl, repository.dir);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/);
|
|
});
|
|
});
|
|
|
|
test('repository governance config resolves the current four-member panel and five scopes', async () => {
|
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-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/);
|
|
});
|
|
});
|
|
|
|
test('repository scope mapping covers every tracked path except the ruled residue', () => {
|
|
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
|
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
|
|
.flatMap((match) => JSON.parse(match[1]));
|
|
const tracked = execFileSync('git', ['ls-files'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
encoding: 'utf8',
|
|
}).trim().split('\n');
|
|
const allowed = new Set([
|
|
'.gitignore',
|
|
'assets/logo-mark.svg',
|
|
...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)),
|
|
]);
|
|
const uncovered = tracked.filter((filename) => (
|
|
!allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob))
|
|
));
|
|
|
|
assert.deepEqual(uncovered, []);
|
|
});
|
|
|
|
test('package lock versions match the package manifest', () => {
|
|
const manifest = JSON.parse(fs.readFileSync(PACKAGE_MANIFEST, 'utf8'));
|
|
const lock = JSON.parse(fs.readFileSync(PACKAGE_LOCK, 'utf8'));
|
|
|
|
assert.equal(lock.version, manifest.version, 'package-lock.json version is stale');
|
|
assert.equal(lock.packages[''].version, manifest.version, 'package-lock.json root package version is stale');
|
|
});
|
|
|
|
test('repository carries the complete Forgejo 0.6.3 doctrine mirror and root router', () => {
|
|
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
|
|
for (const filename of vendored) {
|
|
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
|
|
}
|
|
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
|
|
const sourceVersionRecord = `[heavy-duty/ceremony](${CEREMONY_REPOSITORY}) at ${CEREMONY_VERSION}`;
|
|
assert.equal(
|
|
mirrorReadme.split(sourceVersionRecord).length - 1,
|
|
2,
|
|
'mirror README does not identify the exact Forgejo ceremony source and version in both records',
|
|
);
|
|
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
|
|
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
|
|
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
|
|
const rootAgents = fs.readFileSync(ROOT_AGENTS, 'utf8');
|
|
assert.ok(
|
|
rootAgents.includes(`[heavy-duty/ceremony](${CEREMONY_REPOSITORY})`),
|
|
'root router does not identify the Forgejo ceremony repository',
|
|
);
|
|
assert.match(rootAgents, /read\s+`.ceremony\/AGENTS\.md` first/i);
|
|
});
|
|
|
|
test('repository workflow pins use the exact Forgejo ceremony version', () => {
|
|
for (const workflow of CEREMONY_WORKFLOWS) {
|
|
const contents = fs.readFileSync(path.join(__dirname, '..', '.forgejo', 'workflows', workflow), 'utf8');
|
|
const prefix = `uses: heavy-duty/ceremony/.github/workflows/${workflow}@`;
|
|
const pins = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith(prefix));
|
|
assert.deepEqual(
|
|
pins,
|
|
[`${prefix}${CEREMONY_VERSION}`],
|
|
`${workflow} does not pin ceremony ${CEREMONY_VERSION}`,
|
|
);
|
|
}
|
|
});
|