Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/fork-ci-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
.github/scripts/fork-secret-scan.test.sh
.github/scripts/fork-test-shards.test.cjs
.github/scripts/fork-tests-drift.test.cjs
.github/scripts/fork-upstream-files-check.test.cjs
.github/scripts/fork-workflow-triggers.test.cjs
fork-ci/actions/docs-scope/docs-scope.test.py
fork-ci/actions/fleet-route/fleet_route.test.py
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/fork-schema-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const CHECK_FILES = [
CHECK_SCRIPT,
".github/scripts/fork-schema-check.test.cjs",
".github/workflows/fork-checks.yml",
".github/scripts/fork-upstream-files-check.cjs",
".github/scripts/fork-upstream-files-check.test.cjs",
];
const GITATTRIBUTES = /(^|\/)\.gitattributes$/;

Expand Down
6 changes: 4 additions & 2 deletions .github/scripts/fork-schema-check.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,10 @@ test("refuses changes to the check itself and to .gitattributes once the base ha
changed(file, "a", "b"),
);
const reasons = findSchemaChanges(changes, noClosure);
assert.equal(reasons.length, 5);
for (const reason of reasons.slice(0, 3)) assert.match(reason, /Only the owner may do that/);
assert.equal(reasons.length, CHECK_FILES.length + 2);
for (const reason of reasons.slice(0, CHECK_FILES.length)) {
assert.match(reason, /Only the owner may do that/);
}
// The series that first adds the check (base is an upstream tag) may add its files.
assert.equal(findSchemaChanges(changes, { closure: new Set(), baseHasCheck: false }).length, 2);
});
Expand Down
105 changes: 105 additions & 0 deletions .github/scripts/fork-upstream-files-check.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Refuses fork changes to listed upstream files, so every upstream sync applies to them cleanly.
// Usage: fork-upstream-files-check.cjs <base> <head> <files>. Judged like fork-schema-check.cjs: the
// diff from the merge-base of <base> and <head>, so upstream changes that reach <base> through
// fork-follow's rebase are never the branch's. Files are read as git tree entries; nothing runs.

const { execFileSync } = require("node:child_process");
const { parseRawDiff } = require("./fork-schema-check.cjs");

function git(args) {
return execFileSync("git", ["-c", "core.quotePath=false", ...args], {
encoding: "utf8",
maxBuffer: 512 * 1024 * 1024,
});
}

/** The tree entry of `file` in `rev` ("<mode> <type> <sha>"), or undefined when absent. */
function entryAt(rev, file) {
const record = git(["ls-tree", "-z", "--full-tree", rev, "--", file]).split("\0")[0];
const match = record.match(/^(\d+ \w+ [0-9a-f]+)\t(.+)$/s);
return match && match[2] === file ? match[1] : undefined;
}

/** The list from the config line(s): paths separated by whitespace, each once. */
const parseList = (args) => [...new Set(args.join(" ").split(/\s+/).filter(Boolean))];

/**
* One reason per refused file. `listed`: the guarded paths. `changed`: paths the branch changes
* (merge-base to head). `atBase`/`atHead`: a path's tree entry there (undefined when absent).
*/
function findUpstreamFileChanges(listed, changed, { atBase, atHead }) {
const reasons = [];
const touched = new Set(changed);
for (const file of listed) {
const base = atBase(file);
// A listed file main does not have guards nothing: the list is out of date (upstream moved or
// removed it). Fail closed, so the guard is never silently empty.
if (base === undefined) {
reasons.push(
`${file}: listed, but main does not have it (upstream moved or removed it?). Update the ` +
"list in .github/workflows/fork-checks.yml; only the owner may do that.",
);
continue;
}
// A directory (or submodule) never shows up as one changed path, so edits inside it would pass.
if (base.split(" ")[1] !== "blob") {
reasons.push(
`${file}: listed, but on main it is not a file. List files only, in ` +
".github/workflows/fork-checks.yml; only the owner may do that.",
);
continue;
}
if (!touched.has(file)) continue;
const head = atHead(file);
// The branch carries main's copy (e.g. it sits on the same upstream as main, reached another
// way): it changes nothing main does not already have.
if (head === base) continue;
const what =
head === undefined
? "deletes or renames it"
: head.split(" ")[2] === base.split(" ")[2]
? "changes its file mode"
: "changes it";
reasons.push(`${file}: this branch ${what}`);
}
return reasons;
}

function main(argv) {
const [base, head, ...rest] = argv;
const listed = parseList(rest);
if (!base || !head || listed.length === 0) {
console.error(
"Usage: fork-upstream-files-check.cjs <base> <head> <files (whitespace-separated)>",
);
return 2;
}
const mergeBase = git(["merge-base", base, head]).trim();
const changed = parseRawDiff(
git(["diff", "--raw", "-z", "--no-abbrev", "--no-renames", "--no-ext-diff", mergeBase, head]),
).map((entry) => entry.path);
const reasons = findUpstreamFileChanges(listed, changed, {
atBase: (file) => entryAt(base, file),
atHead: (file) => entryAt(head, file),
});
if (reasons.length === 0) {
console.log(
`No upstream file changes (${listed.length} listed files, ${changed.length} changed files checked).`,
);
return 0;
}
console.error("Fork changes must leave these upstream files exactly as upstream has them:");
for (const reason of reasons) console.error(`- ${reason}`);
console.error(
"Put the change in a file of the fork's own (its own layer, toolkit or budget file). If this " +
"branch sits on an older main, rebase it onto main first. Only the owner may change these " +
"files: the check stays red, and the owner merges past it.",
);
return 1;
}

module.exports = { findUpstreamFileChanges, parseList };

if (require.main === module) {
process.exitCode = main(process.argv.slice(2));
}
Loading
Loading