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 }}