diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt
index 5ea5028a0a752..3f2d9db50764e 100644
--- a/.github/fork-ci-tests.txt
+++ b/.github/fork-ci-tests.txt
@@ -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
diff --git a/.github/scripts/fork-schema-check.cjs b/.github/scripts/fork-schema-check.cjs
index 21ba11a6359c8..c08e48bfbccd9 100644
--- a/.github/scripts/fork-schema-check.cjs
+++ b/.github/scripts/fork-schema-check.cjs
@@ -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$/;
diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs
index 5a458afb7d334..7ad594efe614e 100644
--- a/.github/scripts/fork-schema-check.test.cjs
+++ b/.github/scripts/fork-schema-check.test.cjs
@@ -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);
});
diff --git a/.github/scripts/fork-upstream-files-check.cjs b/.github/scripts/fork-upstream-files-check.cjs
new file mode 100644
index 0000000000000..bc1dd855f4a89
--- /dev/null
+++ b/.github/scripts/fork-upstream-files-check.cjs
@@ -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
. Judged like fork-schema-check.cjs: the
+// diff from the merge-base of and , so upstream changes that reach 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` (" "), 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 ",
+ );
+ 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));
+}
diff --git a/.github/scripts/fork-upstream-files-check.test.cjs b/.github/scripts/fork-upstream-files-check.test.cjs
new file mode 100644
index 0000000000000..da675d172a0dc
--- /dev/null
+++ b/.github/scripts/fork-upstream-files-check.test.cjs
@@ -0,0 +1,390 @@
+const assert = require("node:assert/strict");
+const { execFileSync, spawnSync } = require("node:child_process");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+const { findUpstreamFileChanges, parseList } = require("./fork-upstream-files-check.cjs");
+const { CHECK_FILES } = require("./fork-schema-check.cjs");
+
+const SCRIPT = path.join(__dirname, "fork-upstream-files-check.cjs");
+const REPO_ROOT = path.join(__dirname, "..", "..");
+const WORKFLOW = path.join(REPO_ROOT, ".github", "workflows", "fork-checks.yml");
+
+// The list is one config line in the workflow; the tests read it there, so they follow it.
+const CONFIG_LINE = /^ +UPSTREAM_FILES: (.+)$/m;
+const configured = (workflow) => parseList([workflow.match(CONFIG_LINE)?.[1] ?? ""]);
+const LISTED = configured(fs.readFileSync(WORKFLOW, "utf8"));
+const OTHER = "apps/server/src/feature/Store.ts";
+
+const entries = (files) => (file) => files[file];
+const judge = (changed, base, head) =>
+ findUpstreamFileChanges(["a.ts", "b.json"], changed, {
+ atBase: entries(base),
+ atHead: entries(head),
+ });
+const BASE = { "a.ts": "100644 blob 1", "b.json": "100644 blob 2" };
+
+test("parseList splits the config line on any whitespace and drops repeats", () => {
+ assert.deepEqual(parseList([" a.ts\tb.json a.ts\n"]), ["a.ts", "b.json"]);
+ assert.deepEqual(parseList(["a.ts", "b.json"]), ["a.ts", "b.json"]);
+ assert.deepEqual(parseList([""]), []);
+});
+
+test("an edit, a delete and a mode change of a listed file are refused, each named", () => {
+ const reasons = judge(["a.ts", "b.json"], BASE, { "a.ts": "100755 blob 1" });
+ assert.deepEqual(reasons, [
+ "a.ts: this branch changes its file mode",
+ "b.json: this branch deletes or renames it",
+ ]);
+ assert.deepEqual(judge(["a.ts"], BASE, { ...BASE, "a.ts": "100644 blob 9" }), [
+ "a.ts: this branch changes it",
+ ]);
+});
+
+test("a branch that carries main's copy of a listed file is not refused", () => {
+ assert.deepEqual(judge(["a.ts"], BASE, BASE), []);
+});
+
+test("a listed directory fails closed: edits inside it are not one changed path", () => {
+ const reasons = judge(["a.ts/x.ts"], { ...BASE, "a.ts": "040000 tree 1" }, BASE);
+ assert.equal(reasons.length, 1);
+ assert.match(reasons[0], /^a\.ts: listed, but on main it is not a file/);
+});
+
+test("a listed file main does not have fails closed, whatever the branch does", () => {
+ const reasons = judge([], { "a.ts": BASE["a.ts"] }, BASE);
+ assert.equal(reasons.length, 1);
+ assert.match(reasons[0], /^b\.json: listed, but main does not have it/);
+});
+
+// Read from git, not the disk: CI's lint job checks out only some folders.
+test("the config line lists files that this commit has", () => {
+ assert.ok(LISTED.length > 0);
+ for (const file of LISTED) {
+ const entry = execFileSync("git", ["ls-tree", "--full-tree", "HEAD", "--", file], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ });
+ assert.match(entry, /^\d+ blob [0-9a-f]+\t/, file);
+ }
+});
+
+// Why the workflow does not run the check as intended; empty when it does.
+function workflowProblems(workflow) {
+ const problems = [];
+ const expect = (ok, why) => ok || problems.push(why);
+ const step = workflow.match(
+ /^ {6}- name: Refuse changes to upstream files[^\n]*\n((?: {8}.*\n|\n)+)/m,
+ );
+ expect(step, "no step runs the upstream files check");
+ const body = step?.[1] ?? "";
+ expect(
+ /^ {8}run: node \.github\/scripts\/fork-upstream-files-check\.cjs "\$BASE_SHA" "\$HEAD_SHA" "\$UPSTREAM_FILES"$/m.test(
+ body,
+ ),
+ "the step does not run the check on $BASE_SHA $HEAD_SHA with the list",
+ );
+ expect(CONFIG_LINE.test(body), "the list is not the step's UPSTREAM_FILES line");
+ expect(
+ /^ {8}if: \$\{\{ !cancelled\(\) \}\}$/m.test(body),
+ "the step is skipped when the schema step refuses",
+ );
+ expect(
+ /^ {10}BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}$/m.test(body) &&
+ /^ {10}HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}$/m.test(body),
+ "the step does not judge the pull request's base and head",
+ );
+ expect(
+ /run: node --test .*fork-upstream-files-check\.test\.cjs/.test(workflow),
+ "the workflow does not run these tests",
+ );
+ // The step must be in the guarded job, not a new job or workflow.
+ const jobs = workflow.split(/^ {2}(?=\w+:\n)/m);
+ const job = jobs.find((part) => part.includes('fork-upstream-files-check.cjs "'));
+ expect(job?.startsWith("no_schema_changes:"), "the step is not in the no_schema_changes job");
+ return problems;
+}
+
+test("the workflow runs the check in the schema job, on the PR's base and head, with the list", () => {
+ assert.deepEqual(workflowProblems(fs.readFileSync(WORKFLOW, "utf8")), []);
+});
+
+test("a workflow that drops, skips or mis-feeds the check fails the contract", () => {
+ const workflow = fs.readFileSync(WORKFLOW, "utf8");
+ const mutants = {
+ "no list": workflow.replace(CONFIG_LINE, ""),
+ "no if": workflow.replace(/^ {8}if: \$\{\{ !cancelled\(\) \}\}\n/m, ""),
+ "base for head": workflow.replace(
+ /"\$BASE_SHA" "\$HEAD_SHA" "\$UPSTREAM_FILES"/,
+ '"$HEAD_SHA" "$HEAD_SHA" "$UPSTREAM_FILES"',
+ ),
+ "no tests": workflow.replace(" .github/scripts/fork-upstream-files-check.test.cjs", ""),
+ "no step": workflow.replace(
+ /^ {6}- name: Refuse changes to upstream files[^\n]*\n(?: {8}.*\n)+/m,
+ "",
+ ),
+ };
+ for (const [label, text] of Object.entries(mutants)) {
+ assert.notEqual(text, workflow, label);
+ assert.notDeepEqual(workflowProblems(text), [], label);
+ }
+});
+
+test("the check's own files are guarded by the schema check (a PR cannot weaken it)", () => {
+ for (const file of [SCRIPT, `${SCRIPT.replace(/\.cjs$/, "")}.test.cjs`, WORKFLOW]) {
+ assert.ok(CHECK_FILES.includes(path.relative(REPO_ROOT, file)), file);
+ }
+});
+
+function scratchRepo() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-upstream-files-check-"));
+ const env = {
+ ...process.env,
+ GIT_CONFIG_GLOBAL: os.devNull,
+ GIT_CONFIG_NOSYSTEM: "1",
+ GIT_AUTHOR_NAME: "test",
+ GIT_AUTHOR_EMAIL: "test@example.invalid",
+ GIT_COMMITTER_NAME: "test",
+ GIT_COMMITTER_EMAIL: "test@example.invalid",
+ };
+ const git = (...args) => execFileSync("git", args, { cwd: dir, env, encoding: "utf8" }).trim();
+ const commit = (files, message = "change") => {
+ for (const [file, text] of Object.entries(files)) {
+ const target = path.join(dir, file);
+ if (text === null) {
+ fs.rmSync(target);
+ continue;
+ }
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, text);
+ }
+ git("add", "-A");
+ git("commit", "-q", "--allow-empty", "-m", message);
+ return git("rev-parse", "HEAD");
+ };
+ git("init", "-q", "-b", "main");
+ const check = (base, head, list = LISTED.join(" ")) => {
+ const result = spawnSync(process.execPath, [SCRIPT, base, head, list], {
+ cwd: dir,
+ env,
+ encoding: "utf8",
+ });
+ return { code: result.status, output: result.stdout + result.stderr };
+ };
+ const done = () => fs.rmSync(dir, { recursive: true, force: true });
+ return { dir, git, commit, check, done };
+}
+
+const UPSTREAM_TREE = {
+ [LISTED[0]]: "export const orchestrator = 1;\n",
+ [LISTED[1]]: '{ "budget": 1 }\n',
+ [OTHER]: "export const x = 1;\n",
+};
+
+function prAgainstBase(files) {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ const head = repo.commit(files, "pr");
+ return repo.check(base, head);
+ } finally {
+ repo.done();
+ }
+}
+
+for (const file of LISTED) {
+ test(`e2e: a pull request that edits ${path.basename(file)} is refused, naming it`, () => {
+ const result = prAgainstBase({ [file]: `${UPSTREAM_TREE[file]}// fork\n` });
+ assert.equal(result.code, 1, result.output);
+ assert.ok(result.output.includes(`- ${file}: this branch changes it`), result.output);
+ assert.match(result.output, /the owner merges past it/);
+ });
+
+ test(`e2e: deleting or renaming ${path.basename(file)} is refused`, () => {
+ const deleted = prAgainstBase({ [file]: null });
+ assert.equal(deleted.code, 1, deleted.output);
+ assert.ok(
+ deleted.output.includes(`- ${file}: this branch deletes or renames it`),
+ deleted.output,
+ );
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ repo.git("mv", file, `${file}.moved`);
+ const renamed = repo.check(base, repo.commit({}, "rename"));
+ assert.equal(renamed.code, 1, renamed.output);
+ assert.ok(
+ renamed.output.includes(`- ${file}: this branch deletes or renames it`),
+ renamed.output,
+ );
+ } finally {
+ repo.done();
+ }
+ });
+}
+
+test("e2e: a mode change of a listed file is refused", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ repo.git("update-index", "--chmod=+x", LISTED[0]);
+ repo.git("commit", "-q", "-m", "chmod");
+ const result = repo.check(base, repo.git("rev-parse", "HEAD"));
+ assert.equal(result.code, 1, result.output);
+ assert.ok(
+ result.output.includes(`- ${LISTED[0]}: this branch changes its file mode`),
+ result.output,
+ );
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: an unrelated pull request passes and says what it checked; a rerun says the same", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ const head = repo.commit({ [OTHER]: "export const x = 2;\n", "README.md": "fork\n" }, "pr");
+ const first = repo.check(base, head);
+ assert.equal(first.code, 0, first.output);
+ assert.match(
+ first.output,
+ /No upstream file changes \(2 listed files, 2 changed files checked\)/,
+ );
+ assert.deepEqual(repo.check(base, head), first);
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: an empty pull request (head is the base) passes", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ const result = repo.check(base, base);
+ assert.equal(result.code, 0, result.output);
+ assert.match(result.output, /0 changed files checked/);
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: a branch that edits a listed file and then reverts the edit passes", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ repo.commit({ [LISTED[0]]: "// tried\n" }, "try");
+ const head = repo.commit({ [LISTED[0]]: UPSTREAM_TREE[LISTED[0]] }, "revert");
+ passes(repo.check(base, head));
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: the list is read from the arguments, so a new path is guarded once it is listed", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ const head = repo.commit({ [OTHER]: "export const x = 2;\n" }, "pr");
+ passes(repo.check(base, head));
+ const listed = repo.check(base, head, `${LISTED.join(" ")} ${OTHER}`);
+ assert.equal(listed.code, 1, listed.output);
+ assert.ok(listed.output.includes(`- ${OTHER}: this branch changes it`), listed.output);
+ const none = repo.check(base, head, " ");
+ assert.equal(none.code, 2, none.output);
+ assert.match(none.output, /Usage:/);
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: a listed file main no longer has fails every pull request, naming the list", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "upstream");
+ const head = repo.commit({ [OTHER]: "export const x = 2;\n" }, "pr");
+ const result = repo.check(base, head, `${LISTED.join(" ")} apps/server/src/Gone.ts`);
+ assert.equal(result.code, 1, result.output);
+ assert.match(result.output, /Gone\.ts: listed, but main does not have it/);
+ assert.match(result.output, /fork-checks\.yml/);
+ } finally {
+ repo.done();
+ }
+});
+
+// Upstream changes a listed file; fork-follow rebases the fork's series onto it and moves main.
+function upstreamSync() {
+ const repo = scratchRepo();
+ repo.commit({ ...UPSTREAM_TREE, [LISTED[0]]: "export const orchestrator = 0;\n" }, "upstream v0");
+ const upstreamOld = repo.commit(UPSTREAM_TREE, "upstream v1");
+ const series = repo.commit({ "fork/feature.ts": "export {};\n" }, "fork: feature");
+ repo.git("branch", "old-main", series);
+ repo.git("checkout", "-q", "-b", "upstream", upstreamOld);
+ const upstreamNew = repo.commit(
+ { [LISTED[0]]: "export const orchestrator = 2;\n", [LISTED[1]]: '{ "budget": 2 }\n' },
+ "upstream v2",
+ );
+ repo.git("checkout", "-q", "-b", "new-main", upstreamNew);
+ repo.git("cherry-pick", series);
+ const newMain = repo.git("rev-parse", "HEAD");
+ return { repo, upstreamOld, upstreamNew, series, newMain };
+}
+const PASSED = /No upstream file changes \(2 listed files, \d+ changed files checked\)/;
+function passes(result) {
+ assert.equal(result.code, 0, result.output);
+ assert.match(result.output, PASSED);
+}
+
+test("e2e: an upstream sync stays green: fork-follow's series and a PR on the new main", () => {
+ const { repo, upstreamNew, newMain } = upstreamSync();
+ try {
+ // fork-follow judges the series against the upstream release it sits on.
+ passes(repo.check(upstreamNew, newMain));
+ // A new pull request on the moved main.
+ repo.git("checkout", "-q", "-b", "pr", newMain);
+ const pr = repo.commit({ [OTHER]: "export const x = 2;\n" }, "pr");
+ passes(repo.check(newMain, pr));
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: a PR whose base moved (main rebased onto a newer upstream) is not blamed for upstream", () => {
+ const { repo, upstreamNew, series, newMain } = upstreamSync();
+ try {
+ // A pull request opened from the old main, before the sync.
+ repo.git("checkout", "-q", "-b", "pr", series);
+ const old = repo.commit({ [OTHER]: "export const x = 2;\n" }, "pr");
+ passes(repo.check(newMain, old));
+ // The same branch takes upstream's change another way (a cherry-pick, not a rebase onto main):
+ // its merge-base with main stays old, but it carries main's copies, so it changes nothing.
+ repo.git("cherry-pick", upstreamNew);
+ passes(repo.check(newMain, repo.git("rev-parse", "HEAD")));
+ // A real edit is still refused against the moved base.
+ const edit = repo.commit({ [LISTED[1]]: '{ "budget": 3 }\n' }, "edit budget");
+ const refused = repo.check(newMain, edit);
+ assert.equal(refused.code, 1, refused.output);
+ assert.ok(refused.output.includes(`- ${LISTED[1]}: this branch changes it`), refused.output);
+ } finally {
+ repo.done();
+ }
+});
+
+test("e2e: a branch behind a main that moved forward normally is judged on its own edits", () => {
+ const repo = scratchRepo();
+ try {
+ const base = repo.commit(UPSTREAM_TREE, "fork main");
+ repo.git("checkout", "-q", "-b", "pr");
+ const unrelated = repo.commit({ "README.md": "notes\n" }, "docs");
+ repo.git("checkout", "-q", "main");
+ const moved = repo.commit({ [OTHER]: "export const x = 9;\n" }, "fork: more");
+ passes(repo.check(moved, unrelated));
+ repo.git("checkout", "-q", "pr");
+ const edit = repo.commit({ [LISTED[0]]: "// fork\n" }, "edit");
+ assert.equal(repo.check(moved, edit).code, 1);
+ assert.equal(repo.check(base, edit).code, 1);
+ } finally {
+ repo.done();
+ }
+});
diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml
index c64602abd5cec..e8575f505de60 100644
--- a/.github/workflows/fork-checks.yml
+++ b/.github/workflows/fork-checks.yml
@@ -23,6 +23,8 @@ permissions:
# request". A head repository GitHub no longer knows reads as "another fork". Test:
# fork-contracts.test.py.
jobs:
+ # It also refuses changes to the upstream files listed in its last step. Keep the name: it is what
+ # a ruleset would require.
no_schema_changes:
name: No database schema changes
if: ${{ (!github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository) }}
@@ -47,7 +49,7 @@ jobs:
git checkout -q --detach "$DEFAULT_SHA"
- name: Test the check
- run: node --test .github/scripts/fork-schema-check.test.cjs
+ run: node --test .github/scripts/fork-schema-check.test.cjs .github/scripts/fork-upstream-files-check.test.cjs
# T3 has no down-migrations, so a fork migration strands installs that return to upstream.
- name: Refuse schema and migration changes
@@ -56,6 +58,18 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node .github/scripts/fork-schema-check.cjs "$BASE_SHA" "$HEAD_SHA"
+ # The fork's add-ons live in their own files and layers, so these upstream files stay exactly
+ # upstream's (upstream's own changes reach main through fork-follow's rebase, not a PR). Runs
+ # even when the schema step refused, so a PR sees both reasons.
+ - name: Refuse changes to upstream files the fork leaves alone
+ if: ${{ !cancelled() }}
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ # The list, separated by spaces. To guard one more file, add its path here.
+ UPSTREAM_FILES: apps/server/src/orchestration-v2/Orchestrator.ts apps/server/src/provider/agentContextBudget.json
+ run: node .github/scripts/fork-upstream-files-check.cjs "$BASE_SHA" "$HEAD_SHA" "$UPSTREAM_FILES"
+
outside_pr:
name: Outside pull request
if: ${{ github.event.pull_request && github.event.pull_request.head.repo.full_name != github.repository }}