Skip to content
Draft
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
15 changes: 9 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions packages/react-doctor/tests/github-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down
Loading