mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-09 17:48:01 +08:00
136 lines
5.4 KiB
JavaScript
136 lines
5.4 KiB
JavaScript
// Fingerprints the files whose change starts the source baseline check on a
|
|
// pull request: exactly the `on.pull_request.paths` of source-baseline.yml,
|
|
// read from the checkout being verified. workflow-policy.test.mjs keeps every
|
|
// script the check runs inside those filters, and pull request runs verify the
|
|
// pinned source SHA that source-baseline-ref.mjs, one of those files, declares.
|
|
// It is not a complete description of a run: Node.js 24.x, the runner image,
|
|
// the npm registry and the overlay dependencies the portable job installs
|
|
// without a lockfile are outside it. Two runs of one pull request with the same
|
|
// fingerprint ran the same factory files against the same source, which is
|
|
// what a pull request check is for; a re-run still verifies in full.
|
|
//
|
|
// The workflow runs this file from the default branch, never from the pull
|
|
// request it fingerprints.
|
|
//
|
|
// Usage: node source-baseline-inputs.mjs <checkout>
|
|
// Prints a hex SHA-256 over the matching paths and their blob ids.
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
export const WORKFLOW = '.github/workflows/source-baseline.yml';
|
|
|
|
// One list item: `- path`, `- 'path'` or `- "path"`, optionally followed by a
|
|
// comment. Escapes inside double quotes are not read.
|
|
const ITEM = /^ {6}- (?:'([^']*)'|"([^"\\]*)"|([^\s'"#][^\s#]*))(?:\s+#.*)?$/;
|
|
|
|
// The paths list of the pull_request trigger. A strict reading of the one
|
|
// block, not a YAML parser, since the checkout has no dependencies installed.
|
|
// Every line of the list must be blank, a comment or an item of one of the
|
|
// forms above. Anything else fails, so the fingerprint can never silently
|
|
// cover fewer files than the trigger does.
|
|
export function pullRequestPaths(workflow) {
|
|
const lines = workflow.split(/\r?\n/);
|
|
let index = lines.indexOf(' pull_request:');
|
|
assert.ok(index >= 0, 'source-baseline.yml has no pull_request trigger');
|
|
const insideTrigger = (line) =>
|
|
line.trim() === '' || /^\s*#/.test(line) || /^ {4}/.test(line);
|
|
for (index += 1; index < lines.length; index += 1)
|
|
if (!insideTrigger(lines[index]) || lines[index] === ' paths:') break;
|
|
assert.equal(
|
|
lines[index],
|
|
' paths:',
|
|
'the pull_request trigger has no paths',
|
|
);
|
|
const paths = [];
|
|
for (index += 1; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
if (line.trim() === '' || /^\s*#/.test(line)) continue;
|
|
if (!/^ {6}/.test(line)) break;
|
|
const item = ITEM.exec(line);
|
|
assert.ok(item, `unreadable pull_request path entry: ${line.trim()}`);
|
|
const value = item[1] ?? item[2] ?? item[3];
|
|
assert.ok(value, `empty pull_request path entry: ${line.trim()}`);
|
|
paths.push(value);
|
|
}
|
|
assert.ok(paths.length, 'the pull_request paths are empty');
|
|
return paths;
|
|
}
|
|
|
|
// GitHub's filter syntax, limited to what the list uses: literal paths, `*`
|
|
// within one path segment and `**` across segments (zero or more of them). Anything else (`?`, `[`,
|
|
// `!` negations) is refused, so a new kind of pattern makes this fail loudly
|
|
// instead of hashing a different set than the trigger.
|
|
export function pathMatcher(patterns) {
|
|
const expressions = patterns.map((pattern) => {
|
|
assert.match(pattern, /^[\w./*-]+$/, `unsupported path filter: ${pattern}`);
|
|
assert.doesNotMatch(
|
|
pattern,
|
|
/\*{3}/,
|
|
`unsupported path filter: ${pattern}`,
|
|
);
|
|
// `/**/` also matches no directory at all (`a/**/b` matches `a/b`), as in
|
|
// GitHub's filters; any other `**` matches across segments. Pattern and
|
|
// path both get a leading slash, so a leading `**/` matches the root too.
|
|
const source = `/${pattern}`
|
|
.split('/**/')
|
|
.map((section) =>
|
|
section
|
|
.split('**')
|
|
.map((part) =>
|
|
part
|
|
.split('*')
|
|
.map((literal) => literal.replace(/[.]/g, '\\.'))
|
|
.join('[^/]*'),
|
|
)
|
|
.join('.*'),
|
|
)
|
|
.join('/(?:.*/)?');
|
|
return new RegExp(`^${source}$`);
|
|
});
|
|
return (file) =>
|
|
expressions.some((expression) => expression.test(`/${file}`));
|
|
}
|
|
|
|
export function inputFingerprint(checkout) {
|
|
const matches = pathMatcher(
|
|
pullRequestPaths(readFileSync(path.join(checkout, WORKFLOW), 'utf8')),
|
|
);
|
|
// Blob ids from the index of the checked-out commit: content-addressed, so
|
|
// the line endings or timestamps of the working tree play no part.
|
|
const entries = execFileSync(
|
|
'git',
|
|
['-C', checkout, 'ls-files', '-s', '-z'],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
},
|
|
)
|
|
.split('\0')
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [meta, file] = line.split('\t');
|
|
const [mode, blob] = meta.split(' ');
|
|
return { file, mode, blob };
|
|
})
|
|
.filter(({ file }) => matches(file))
|
|
.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
assert.ok(
|
|
entries.some(({ file }) => file === WORKFLOW),
|
|
'the workflow itself must be one of its inputs',
|
|
);
|
|
const hash = createHash('sha256');
|
|
for (const { file, mode, blob } of entries)
|
|
hash.update(`${mode} ${blob}\t${file}\n`);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
const checkout = process.argv[2];
|
|
assert.ok(checkout, 'Usage: source-baseline-inputs.mjs <checkout>');
|
|
console.log(inputFingerprint(checkout));
|
|
}
|