Repository navigation
Expand file tree
/
Copy pathci-scope.mjs
More file actions
96 lines (88 loc) · 5.03 KB
/
Copy pathci-scope.mjs
File metadata and controls
96 lines (88 loc) · 5.03 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
import { appendFileSync, readFileSync, realpathSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const rootDocs = new Set([
'README.md', ...['en', 'de-DE', 'zh-CN', 'zh-TW', 'ja', 'ko', 'pt-BR', 'id'].map(locale => `README-${locale}.md`),
'AGENTS.md', 'AGENTS.zh-CN.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'CHANGELOG.md',
'LOCAL-DATA.md', 'LOCAL-DATA.zh-CN.md', 'ARCHITECTURE.md', 'ARCHITECTURE-zh-CN.md',
]);
const shaPattern = /^[a-f0-9]{40}$/;
const validSha = value => typeof value === 'string' && shaPattern.test(value) && !/^0+$/.test(value);
function documentationPath(value) {
if (typeof value !== 'string' || /[\x00-\x1f\x7f\\]/.test(value)) return false;
const parts = value.split('/');
if (parts.some(part => !part || part === '.' || part === '..')) return false;
return rootDocs.has(value) || (parts[0] === 'docs' && parts.length > 1 && value.endsWith('.md'));
}
// True is the safe default: unknown paths, modes, statuses and empty diffs run UI.
export function classifyEntries(entries) {
if (!Array.isArray(entries) || entries.length === 0) return true;
return !entries.every(entry => entry && documentationPath(entry.path) &&
['A', 'M', 'D'].includes(entry.status) &&
['100644', '000000'].includes(entry.oldMode) && ['100644', '000000'].includes(entry.newMode) &&
(entry.status === 'A' ? entry.oldMode === '000000' && entry.newMode === '100644' :
entry.status === 'D' ? entry.oldMode === '100644' && entry.newMode === '000000' :
entry.oldMode === '100644' && entry.newMode === '100644'));
}
export function parseRawDiff(raw) {
if (typeof raw !== 'string' || !raw || !raw.endsWith('\0')) throw new Error('Unverified diff');
const fields = raw.split('\0');
fields.pop();
if (fields.length % 2 !== 0) throw new Error('Incomplete raw diff');
const entries = [];
for (let index = 0; index < fields.length; index += 2) {
const match = /^:(\d{6}) (\d{6}) [a-f0-9]{40} [a-f0-9]{40} ([AMD])$/.exec(fields[index]);
if (!match || !fields[index + 1]) throw new Error('Unexpected raw diff');
entries.push({ path: fields[index + 1], oldMode: match[1], newMode: match[2], status: match[3] });
}
return entries;
}
export function classifyRepository({ cwd = process.cwd(), eventName, event, sha }) {
const full = reason => ({ uiRequired: true, reason });
try {
if (!validSha(sha)) return full('unverified-checkout');
const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
if (git('rev-parse', 'HEAD').trim() !== sha) return full('checkout-mismatch');
let base;
if (eventName === 'pull_request') {
base = event?.pull_request?.base?.sha;
const head = event?.pull_request?.head?.sha;
if (!validSha(base) || !validSha(head)) return full('unverified-pr-range');
git('merge-base', '--is-ancestor', head, sha);
} else if (eventName === 'push') {
// A docs-only successor may cancel an unfinished runtime push under the
// same main concurrency group. Never replace that coverage with an exemption.
return full('push-full-verification');
} else return full('unrecognized-event');
// Compare the actual tested tree, not only the PR head or latest single commit.
// --no-renames exposes both old and new paths, preventing a runtime→docs rename exemption.
const entries = parseRawDiff(git('diff', '--raw', '-z', '--no-renames', '--no-abbrev', base, sha, '--'));
const uiRequired = classifyEntries(entries);
return { uiRequired, reason: uiRequired ? 'full-verification' : 'verified-documentation-only' };
} catch { return full('unverified-diff'); }
}
export function gatePasses(scopeResult, uiRequired, shardResult) {
return scopeResult === 'success' && ((uiRequired === 'true' && shardResult === 'success') ||
(uiRequired === 'false' && shardResult === 'skipped'));
}
function main() {
if (process.argv.length === 3 && process.argv[2] === '--gate') {
const passed = gatePasses(process.env.SCOPE_RESULT, process.env.UI_REQUIRED, process.env.SHARDS_RESULT);
console.log(passed ? 'UI gate satisfied by the verified scope and expected shard result.' : 'UI gate failed closed.');
process.exitCode = passed ? 0 : 1;
return;
}
if (process.argv.length !== 2) throw new Error('Unsupported scope arguments');
let result;
try {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
result = classifyRepository({ eventName: process.env.GITHUB_EVENT_NAME, event, sha: process.env.GITHUB_SHA });
} catch { result = { uiRequired: true, reason: 'unverified-event' }; }
console.log(`UI required: ${result.uiRequired}; ${result.reason}`);
if (!process.env.GITHUB_OUTPUT) throw new Error('Missing output channel');
appendFileSync(process.env.GITHUB_OUTPUT, `ui_required=${result.uiRequired}\n`);
}
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
}