diff --git a/action.yml b/action.yml index 6e25c6ba5..3c18932b8 100644 --- a/action.yml +++ b/action.yml @@ -131,6 +131,7 @@ runs: env: INPUT_DIRECTORY: ${{ inputs.directory }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} CHANGED_FILES_FILE: ${{ runner.temp }}/react-doctor-changed-files.txt run: | # Best-effort fetch of the PR base commit so react-doctor can read base @@ -169,13 +170,15 @@ runs: # Derive the changed set locally. Three-dot (merge-base) + AMR # (added/modified/renamed) match the `pulls.listFiles` API contract and - # the CLI's baseline semantics. Attempt it whenever a base SHA is known — - # the base may be reachable in history even when the fetch above was a - # no-op or failed. On any failure (shallow history with no reachable - # merge-base), leave the file unwritten so the API fallback runs — no - # regression vs the previous API-only behavior. + # the CLI's baseline semantics. Compare the event's base SHA to the + # event's head SHA (not the synthetic merge-ref HEAD, which includes + # base-branch drift). Attempt it whenever both SHAs are known — the base + # may be reachable in history even when the fetch above was a no-op or + # failed. On any failure (shallow history with no reachable merge-base), + # leave the file unwritten so the API fallback runs — no regression vs + # the previous API-only behavior. RAW_CHANGED="${RUNNER_TEMP:-/tmp}/react-doctor-raw-changed.txt" - if [ -n "$BASE_SHA" ] && git -C "$INPUT_DIRECTORY" diff --name-only --diff-filter=AMR "$BASE_SHA...HEAD" > "$RAW_CHANGED" 2>/dev/null; then + if [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ] && git -C "$INPUT_DIRECTORY" diff --name-only --diff-filter=AMR "$BASE_SHA...$HEAD_SHA" > "$RAW_CHANGED" 2>/dev/null; then node "$GITHUB_ACTION_PATH/scripts/normalize-changed-files.mjs" "$SCAN_PREFIX" "$CHANGED_FILES_FILE" < "$RAW_CHANGED" echo "path=$CHANGED_FILES_FILE" >> "$GITHUB_OUTPUT" else diff --git a/packages/react-doctor/tests/github-action.test.ts b/packages/react-doctor/tests/github-action.test.ts index 0f41712aa..380d3f783 100644 --- a/packages/react-doctor/tests/github-action.test.ts +++ b/packages/react-doctor/tests/github-action.test.ts @@ -287,6 +287,126 @@ describe("GitHub Action contract", () => { 30_000, ); + itOnPosix( + "compares base to PR head (not synthetic merge-ref HEAD) to exclude base-branch drift", + () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-action-")); + try { + const originDirectory = path.join(fixtureRoot, "origin"); + const checkoutDirectory = path.join(fixtureRoot, "checkout"); + const runnerTemp = path.join(fixtureRoot, "runner-temp"); + fs.mkdirSync(originDirectory); + fs.mkdirSync(checkoutDirectory); + fs.mkdirSync(runnerTemp); + + runGit(originDirectory, "init"); + fs.mkdirSync(path.join(originDirectory, "src"), { recursive: true }); + fs.writeFileSync( + path.join(originDirectory, "src", "app.tsx"), + "export const App = () => null;\n", + ); + runGit(originDirectory, "add", "."); + runGit(originDirectory, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + const baseSha = runGit(originDirectory, "rev-parse", "HEAD"); + + runGit(originDirectory, "checkout", "-b", "pr"); + fs.writeFileSync( + path.join(originDirectory, "src", "feature.tsx"), + "export const Feature = () => null;\n", + ); + runGit(originDirectory, "add", "."); + runGit(originDirectory, "-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const prHeadSha = runGit(originDirectory, "rev-parse", "HEAD"); + + runGit(originDirectory, "checkout", "-"); + for (let index = 0; index < 15; index += 1) { + fs.writeFileSync( + path.join(originDirectory, "src", `drift-${index}.tsx`), + `export const Drift${index} = () => null;\n`, + ); + runGit(originDirectory, "add", "."); + runGit(originDirectory, "-c", "commit.gpgsign=false", "commit", "-m", `drift ${index}`); + } + + runGit(originDirectory, "-c", "advice.detachedHead=false", "checkout", "--detach"); + runGit( + originDirectory, + "-c", + "commit.gpgsign=false", + "merge", + "--no-ff", + "pr", + "-m", + "merge", + ); + runGit(originDirectory, "update-ref", "refs/pull/1/merge", "HEAD"); + + runGit(checkoutDirectory, "init"); + runGit(checkoutDirectory, "remote", "add", "origin", pathToFileURL(originDirectory).href); + runGit( + checkoutDirectory, + "fetch", + "--no-tags", + "origin", + "+refs/pull/1/merge:refs/remotes/pull/1/merge", + ); + runGit( + checkoutDirectory, + "-c", + "advice.detachedHead=false", + "checkout", + "--force", + "refs/remotes/pull/1/merge", + ); + + const changedFilesFile = path.join(runnerTemp, "react-doctor-changed-files.txt"); + const githubOutputFile = path.join(runnerTemp, "github-output.txt"); + fs.writeFileSync(githubOutputFile, ""); + const scriptOutput = execFileSync( + "bash", + [ + "--noprofile", + "--norc", + "-e", + "-o", + "pipefail", + "-c", + extractBaseStepScript(readActionYaml()), + ], + { + cwd: checkoutDirectory, + encoding: "utf8", + env: { + ...GIT_TEST_ENV, + INPUT_DIRECTORY: ".", + BASE_SHA: baseSha, + HEAD_SHA: prHeadSha, + CHANGED_FILES_FILE: changedFilesFile, + RUNNER_TEMP: runnerTemp, + GITHUB_OUTPUT: githubOutputFile, + GITHUB_ACTION_PATH: REPOSITORY_ROOT, + }, + }, + ); + + expect(scriptOutput).not.toContain("could not derive"); + expect(fs.existsSync(changedFilesFile)).toBe(true); + const changedFiles = fs + .readFileSync(changedFilesFile, "utf8") + .split("\n") + .filter((line) => line.trim() !== "") + .sort(); + + expect(changedFiles).toEqual(["src/feature.tsx"]); + expect(changedFiles).not.toContain("src/drift-0.tsx"); + expect(changedFiles).not.toContain("src/drift-14.tsx"); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + 30_000, + ); + it("falls back to a full-project scan when listing PR files is not permitted", () => { const prFilesStep = normalizeWhitespace(extractStep(readActionYaml(), "- id: pr-files"));