Compare commits
No commits in common. "bef059d7b750179b44334e156dd7bc1a8db9dbd8" and "081e05ca5a62570203ec048caf1ce38e9ae3c9e3" have entirely different histories.
bef059d7b7
...
081e05ca5a
6 changed files with 22 additions and 181 deletions
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
|
|
@ -4,13 +4,13 @@
|
||||||
- any-glob-to-any-file: ["src/**"]
|
- any-glob-to-any-file: ["src/**"]
|
||||||
"scope:packaging":
|
"scope:packaging":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"]
|
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"]
|
||||||
"scope:manifests":
|
"scope:manifests":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["manifests/**"]
|
- any-glob-to-any-file: ["manifests/**"]
|
||||||
"scope:ci":
|
"scope:ci":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"]
|
- any-glob-to-any-file: [".forgejo/workflows/**"]
|
||||||
"scope:docs":
|
"scope:docs":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["*.md", "docs/**"]
|
- any-glob-to-any-file: ["README.md", "docs/**"]
|
||||||
|
|
|
||||||
6
.github/labels.conf
vendored
6
.github/labels.conf
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
triage-actors=claude-bot-andresmgsl
|
triage-actors=claude-bot-andresmgsl
|
||||||
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
||||||
scope:packaging|C5DEF5|scripts/, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces
|
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:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
||||||
scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates
|
scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates
|
||||||
scope:docs|C5DEF5|root Markdown and docs/ — the prose contract
|
scope:docs|C5DEF5|README and docs/ — the prose contract
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ authoritative; this table is its human-readable restatement.
|
||||||
| Scope | Covers |
|
| Scope | Covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `scope:cli` | `src/` — the command surface (`cli.js`, `api.js`, and `config.js`) |
|
| `scope:cli` | `src/` — the command surface (`cli.js`, `api.js`, and `config.js`) |
|
||||||
| `scope:packaging` | `scripts/`, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces |
|
| `scope:packaging` | `scripts/` and the release workflow — Debian package builds, registry publishing, and the APT install path |
|
||||||
| `scope:manifests` | `manifests/` — fleet repository registry data |
|
| `scope:manifests` | `manifests/` — fleet repository registry data |
|
||||||
| `scope:ci` | `.forgejo/workflows/`, `.github/`, and `.ceremony/` — the test, label, and governance gates |
|
| `scope:ci` | `.forgejo/workflows/` — test and label gates |
|
||||||
| `scope:docs` | root Markdown and `docs/` — the prose contract |
|
| `scope:docs` | `README.md` and `docs/` — the prose contract |
|
||||||
|
|
||||||
These names and path descriptions restate the scope rows in
|
These names and path descriptions restate the scope rows in
|
||||||
[`.github/labels.conf`](.github/labels.conf).
|
[`.github/labels.conf`](.github/labels.conf).
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48).
|
|
||||||
|
|
@ -2,13 +2,6 @@
|
||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
|
|
||||||
const UNSCOPED_PATHS = [
|
|
||||||
'.gitignore', // Repository plumbing has no product surface.
|
|
||||||
'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy.
|
|
||||||
'test/*.test.js', // Tests inherit the scope of the production surface changed beside them.
|
|
||||||
];
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const options = {
|
const options = {
|
||||||
|
|
@ -62,65 +55,6 @@ function parseConfig(contents, filename) {
|
||||||
return { identities, scopes };
|
return { identities, scopes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLabeler(contents, filename) {
|
|
||||||
const mappings = new Map();
|
|
||||||
let current;
|
|
||||||
for (const line of contents.split(/\r?\n/)) {
|
|
||||||
const label = line.match(/^"([^"]+)":$/);
|
|
||||||
if (label) {
|
|
||||||
current = label[1];
|
|
||||||
if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`);
|
|
||||||
mappings.set(current, []);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/);
|
|
||||||
if (!matcher) continue;
|
|
||||||
if (!current) throw new Error(`scope matcher has no label in ${filename}`);
|
|
||||||
let globs;
|
|
||||||
try {
|
|
||||||
globs = JSON.parse(matcher[1]);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) {
|
|
||||||
throw new Error(`malformed scope matcher for ${current} in ${filename}`);
|
|
||||||
}
|
|
||||||
mappings.get(current).push(...globs);
|
|
||||||
}
|
|
||||||
for (const [label, globs] of mappings) {
|
|
||||||
if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`);
|
|
||||||
}
|
|
||||||
return mappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateScopeNames(scopes, mappings) {
|
|
||||||
const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0]));
|
|
||||||
const labelerNames = new Set(mappings.keys());
|
|
||||||
const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort();
|
|
||||||
const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort();
|
|
||||||
const errors = [];
|
|
||||||
if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`);
|
|
||||||
if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`);
|
|
||||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateTrackedPaths(mappings) {
|
|
||||||
let tracked;
|
|
||||||
try {
|
|
||||||
tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`cannot enumerate tracked paths: ${error.message}`);
|
|
||||||
}
|
|
||||||
const globs = [...mappings.values()].flat();
|
|
||||||
const uncovered = tracked.filter((filename) => (
|
|
||||||
!UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob))
|
|
||||||
&& !globs.some((glob) => path.matchesGlob(filename, glob))
|
|
||||||
));
|
|
||||||
if (uncovered.length > 0) {
|
|
||||||
throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateIdentities(apiUrl, identities) {
|
async function validateIdentities(apiUrl, identities) {
|
||||||
for (const login of identities) {
|
for (const login of identities) {
|
||||||
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
||||||
|
|
@ -138,10 +72,6 @@ async function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const contents = fs.readFileSync(options.config, 'utf8');
|
const contents = fs.readFileSync(options.config, 'utf8');
|
||||||
const { identities, scopes } = parseConfig(contents, options.config);
|
const { identities, scopes } = parseConfig(contents, options.config);
|
||||||
const labeler = path.join(path.dirname(options.config), 'labeler.yml');
|
|
||||||
const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler);
|
|
||||||
validateScopeNames(scopes, mappings);
|
|
||||||
validateTrackedPaths(mappings);
|
|
||||||
await validateIdentities(options.apiUrl, identities);
|
await validateIdentities(options.apiUrl, identities);
|
||||||
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
|
|
@ -27,30 +26,12 @@ function writeConfig(contents) {
|
||||||
cleanups.push(dir);
|
cleanups.push(dir);
|
||||||
const config = path.join(dir, 'labels.conf');
|
const config = path.join(dir, 'labels.conf');
|
||||||
fs.writeFileSync(config, contents);
|
fs.writeFileSync(config, contents);
|
||||||
fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml'));
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeRepository(configContents, labelerContents, files = {}) {
|
function runValidator(config, apiUrl) {
|
||||||
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) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
||||||
cwd,
|
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
|
@ -130,71 +111,6 @@ test('governance validator rejects malformed scope rows before identity requests
|
||||||
assert.doesNotMatch(result.stderr, /fetch failed/);
|
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 () => {
|
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']);
|
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||||
await withIdentityServer(logins, async (apiUrl) => {
|
await withIdentityServer(logins, async (apiUrl) => {
|
||||||
|
|
@ -204,24 +120,20 @@ test('repository governance config resolves the current four-member panel and fi
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repository scope mapping covers every tracked path except the ruled residue', () => {
|
test('repository scope mapping covers every configured scope with the ruled paths', () => {
|
||||||
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
||||||
const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)]
|
const expected = {
|
||||||
.flatMap((match) => JSON.parse(match[1]));
|
'scope:cli': ['src/**'],
|
||||||
const tracked = execFileSync('git', ['ls-files'], {
|
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'],
|
||||||
cwd: path.join(__dirname, '..'),
|
'scope:manifests': ['manifests/**'],
|
||||||
encoding: 'utf8',
|
'scope:ci': ['.forgejo/workflows/**'],
|
||||||
}).trim().split('\n');
|
'scope:docs': ['README.md', 'docs/**'],
|
||||||
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, []);
|
for (const [label, globs] of Object.entries(expected)) {
|
||||||
|
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`);
|
||||||
|
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('package lock versions match the package manifest', () => {
|
test('package lock versions match the package manifest', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue