Skip to content

Commit 4333ad6

Browse files
chore: react@next CI checks (#46)
1 parent 91453e7 commit 4333ad6

9 files changed

Lines changed: 314 additions & 20 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,24 @@ on:
66
pull_request:
77
branches: [main]
88

9+
env:
10+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
11+
912
jobs:
1013
validate:
1114
name: Validate
1215
runs-on: ubuntu-latest
1316
steps:
14-
- uses: actions/checkout@v4
17+
- uses: actions/checkout@v5
1518

1619
- name: Setup deps
1720
uses: ./.github/actions/setup
1821

1922
- name: Build
2023
run: bun run build
2124

22-
- name: Run prettier
23-
run: bun run prettier
25+
- name: Run format
26+
run: bun run format:check
2427

2528
- name: Run linter
2629
run: bun run lint
@@ -32,7 +35,7 @@ jobs:
3235
run: bun run test:ci
3336

3437
- name: Upload coverage to Codecov
35-
uses: codecov/codecov-action@v4
38+
uses: codecov/codecov-action@v5
3639
with:
3740
token: ${{ secrets.CODECOV_TOKEN }}
3841

@@ -41,9 +44,9 @@ jobs:
4144
runs-on: ubuntu-latest
4245
strategy:
4346
matrix:
44-
react-version: ["^19.0.0", "^19.1.0", "^19.2.0", "latest", "next"]
47+
react-version: ["~19.0.0", "~19.1.0", "~19.2.0", "latest"]
4548
steps:
46-
- uses: actions/checkout@v4
49+
- uses: actions/checkout@v5
4750

4851
- name: Setup deps
4952
uses: ./.github/actions/setup

.github/workflows/nightly.yaml

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,30 @@ on:
55
- cron: "0 2 * * *"
66
workflow_dispatch:
77

8+
env:
9+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
10+
811
jobs:
12+
react-release-guard:
13+
name: Guard React Latest Support Window
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v5
17+
18+
- name: Setup deps
19+
uses: ./.github/actions/setup
20+
21+
- name: Check latest React release against supported line
22+
run: bun run check:react-release-guard
23+
924
test-react-versions:
1025
name: Test React ${{ matrix.react-version }}
1126
runs-on: ubuntu-latest
1227
strategy:
1328
matrix:
14-
react-version: ["^19.0.0", "^19.1.0", "^19.2.0", "latest", "next"]
29+
react-version: ["~19.0.0", "~19.1.0", "~19.2.0", "latest"]
1530
steps:
16-
- uses: actions/checkout@v4
31+
- uses: actions/checkout@v5
1732

1833
- name: Setup deps
1934
uses: ./.github/actions/setup
@@ -26,3 +41,24 @@ jobs:
2641

2742
- name: Run tests
2843
run: bun run test:ci
44+
45+
test-canary-react:
46+
name: Test React canary
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v5
50+
51+
- name: Setup deps
52+
uses: ./.github/actions/setup
53+
54+
- name: Install React canary
55+
run: bun add -d react@next
56+
57+
- name: Install react-reconciler canary
58+
run: bun add react-reconciler@next
59+
60+
- name: Build with canary dependencies
61+
run: bun run build
62+
63+
- name: Run tests with canary dependencies
64+
run: bun run test:ci

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ Project docs:
1818
- [Development Conventions](./docs/agents/development-conventions.md)
1919
- [Commands](./docs/agents/commands.md)
2020
- [Versioning](./docs/versioning.md)
21+
22+
PR draft workflow:
23+
24+
- Maintain `PR.txt` at the repository root using the structure from `.github/pull_request_template.md`.
25+
- Keep `PR.txt` aligned with the current branch diff relative to `origin/main`, including tests actually run and any known validation gaps.
26+
- Do not commit `PR.txt`.

docs/agents/commands.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Use Bun to run repository scripts.
88
- `bun run test` runs the Jest test suite.
99
- `bun run test:ci` runs tests with coverage.
1010
- `bun run lint` runs ESLint.
11-
- `bun run prettier` checks formatting.
12-
- `bun run prettier:fix` writes formatting changes.
11+
- `bun run format` checks formatting.
12+
- `bun run format:fix` writes formatting changes.
1313
- `bun run validate` runs typecheck, test, lint, and formatting checks.
1414
- `bun run validate:fix` runs formatting and lint fixes, then typecheck and tests.

eslint.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import simpleImportSort from "eslint-plugin-simple-import-sort";
55
export default tseslint.config(
66
eslint.configs.recommended,
77
...tseslint.configs.strictTypeChecked,
8+
{
9+
files: ["**/*.{js,mjs,cjs}"],
10+
...tseslint.configs.disableTypeChecked,
11+
languageOptions: {
12+
globals: {
13+
console: "readonly",
14+
fetch: "readonly",
15+
process: "readonly",
16+
},
17+
},
18+
},
819
{
920
files: ["src/**/*.{ts,tsx}"],
1021
languageOptions: {

package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,17 @@
3636
],
3737
"scripts": {
3838
"build": "tsup",
39+
"check:react-release-guard": "node scripts/check-react-release-guard.mjs",
3940
"dev": "tsup --watch",
4041
"test": "jest",
4142
"test:ci": "jest --coverage",
4243
"lint": "eslint .",
4344
"typecheck": "tsc --noEmit",
4445
"test:watch": "jest --watch",
45-
"validate": "bun run typecheck && bun run test && bun run lint && bun run prettier",
46-
"validate:fix": "bun run prettier:fix && bun run lint --fix && bun run typecheck && bun run test -u",
47-
"prettier": "prettier --check .",
48-
"prettier:fix": "prettier --write .",
46+
"validate": "bun run typecheck && bun run test && bun run lint && bun run format:check",
47+
"validate:fix": "bun run format:fix && bun run lint --fix && bun run typecheck && bun run test -u",
48+
"format:check": "prettier --check .",
49+
"format:fix": "prettier --write .",
4950
"release": "release-it"
5051
},
5152
"dependencies": {
@@ -55,6 +56,9 @@
5556
"peerDependencies": {
5657
"react": "^19.0.0"
5758
},
59+
"testRenderer": {
60+
"supportedReactRange": ">=19.0.0 <19.3.0"
61+
},
5862
"devDependencies": {
5963
"@eslint/js": "^9.21.0",
6064
"@jest/globals": "^30.2.0",
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import packageJson from "../package.json" with { type: "json" };
2+
3+
function getSupportedReactRange() {
4+
const supportedReactRange = packageJson.testRenderer?.supportedReactRange;
5+
6+
if (typeof supportedReactRange !== "string" || supportedReactRange.trim() === "") {
7+
throw new Error("package.json must define testRenderer.supportedReactRange");
8+
}
9+
10+
return supportedReactRange;
11+
}
12+
13+
function parseSemver(version) {
14+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
15+
16+
if (!match) {
17+
throw new Error(`Unsupported semver format: ${version}`);
18+
}
19+
20+
return {
21+
major: Number(match[1]),
22+
minor: Number(match[2]),
23+
patch: Number(match[3]),
24+
};
25+
}
26+
27+
function compareSemver(left, right) {
28+
if (left.major !== right.major) {
29+
return left.major - right.major;
30+
}
31+
32+
if (left.minor !== right.minor) {
33+
return left.minor - right.minor;
34+
}
35+
36+
return left.patch - right.patch;
37+
}
38+
39+
function incrementCaretUpperBound(version) {
40+
if (version.major > 0) {
41+
return {
42+
major: version.major + 1,
43+
minor: 0,
44+
patch: 0,
45+
};
46+
}
47+
48+
return {
49+
major: 0,
50+
minor: version.minor + 1,
51+
patch: 0,
52+
};
53+
}
54+
55+
function incrementTildeUpperBound(version) {
56+
return {
57+
major: version.major,
58+
minor: version.minor + 1,
59+
patch: 0,
60+
};
61+
}
62+
63+
function parseExclusiveUpperBounds(range) {
64+
const upperBounds = [];
65+
const lessThanMatches = range.matchAll(/<\s*(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
66+
67+
for (const match of lessThanMatches) {
68+
upperBounds.push(parseSemver(match[1]));
69+
}
70+
71+
const caretMatches = range.matchAll(/\^(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
72+
for (const match of caretMatches) {
73+
upperBounds.push(incrementCaretUpperBound(parseSemver(match[1])));
74+
}
75+
76+
const tildeMatches = range.matchAll(/~(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
77+
for (const match of tildeMatches) {
78+
upperBounds.push(incrementTildeUpperBound(parseSemver(match[1])));
79+
}
80+
81+
return upperBounds;
82+
}
83+
84+
function getSupportedReactCeiling(range) {
85+
const upperBounds = parseExclusiveUpperBounds(range);
86+
87+
if (upperBounds.length === 0) {
88+
throw new Error(`Could not derive a supported React ceiling from range: ${range}`);
89+
}
90+
91+
return upperBounds.reduce((lowest, candidate) =>
92+
compareSemver(candidate, lowest) < 0 ? candidate : lowest,
93+
);
94+
}
95+
96+
function formatSemver(version) {
97+
return `${version.major}.${version.minor}.${version.patch}`;
98+
}
99+
100+
function getSupportedLinesLabel(upperBound) {
101+
if (upperBound.patch === 0 && upperBound.minor > 0) {
102+
return `React ${upperBound.major}.0.x through ${upperBound.major}.${upperBound.minor - 1}.x`;
103+
}
104+
105+
if (upperBound.minor === 0 && upperBound.patch === 0) {
106+
return `React ${upperBound.major - 1}.x`;
107+
}
108+
109+
return `React versions <${formatSemver(upperBound)}`;
110+
}
111+
112+
async function getLatestReactVersion() {
113+
const overriddenVersion = process.env.REACT_RELEASE_GUARD_LATEST;
114+
115+
if (overriddenVersion) {
116+
return overriddenVersion;
117+
}
118+
119+
const response = await fetch("https://registry.npmjs.org/react");
120+
121+
if (!response.ok) {
122+
throw new Error(
123+
`Failed to fetch react package metadata: ${response.status} ${response.statusText}`,
124+
);
125+
}
126+
127+
const metadata = await response.json();
128+
const latestVersion = metadata?.["dist-tags"]?.latest;
129+
130+
if (typeof latestVersion !== "string") {
131+
throw new Error("Missing dist-tags.latest in react package metadata");
132+
}
133+
134+
return latestVersion;
135+
}
136+
137+
async function main() {
138+
const latestVersion = await getLatestReactVersion();
139+
const parsedLatest = parseSemver(latestVersion);
140+
const peerRange = packageJson.peerDependencies?.react ?? "<missing>";
141+
const supportedReactRange = getSupportedReactRange();
142+
const supportedReactCeiling = getSupportedReactCeiling(supportedReactRange);
143+
const supportedLinesLabel = getSupportedLinesLabel(supportedReactCeiling);
144+
145+
if (compareSemver(parsedLatest, supportedReactCeiling) < 0) {
146+
console.log(
147+
`React ${latestVersion} is still within the supported window (${supportedLinesLabel}).`,
148+
);
149+
return;
150+
}
151+
152+
const failureLines = [
153+
`React ${latestVersion} has been released on npm, but this repository currently supports only ${supportedLinesLabel}.`,
154+
`A plain npm install of test-renderer is no longer safely constrained for the newest React release.`,
155+
`Current peerDependencies.react: ${peerRange}`,
156+
`Configured supportedReactRange: ${supportedReactRange}`,
157+
`Current derived React ceiling: <${formatSemver(supportedReactCeiling)}`,
158+
`Update testRenderer.supportedReactRange or tighten peerDependencies.react before relying on latest again.`,
159+
];
160+
161+
throw new Error(failureLines.join("\n"));
162+
}
163+
164+
main().catch((error) => {
165+
console.error(error instanceof Error ? error.message : error);
166+
process.exit(1);
167+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { spawnSync } from "node:child_process";
2+
import { resolve } from "node:path";
3+
import { describe, expect, test } from "@jest/globals";
4+
5+
const scriptPath = resolve(process.cwd(), "scripts/check-react-release-guard.mjs");
6+
7+
describe("react release guard", () => {
8+
test("accepts the latest release within the supported React line", () => {
9+
const result = spawnSync(process.execPath, [scriptPath], {
10+
encoding: "utf8",
11+
env: {
12+
...process.env,
13+
REACT_RELEASE_GUARD_LATEST: "19.2.5",
14+
},
15+
});
16+
17+
expect(result.status).toBe(0);
18+
expect(result.stdout).toContain(
19+
"React 19.2.5 is still within the supported window (React 19.0.x through 19.2.x).",
20+
);
21+
expect(result.stderr).toBe("");
22+
});
23+
24+
test("fails when the latest release moves past the supported React line", () => {
25+
const result = spawnSync(process.execPath, [scriptPath], {
26+
encoding: "utf8",
27+
env: {
28+
...process.env,
29+
REACT_RELEASE_GUARD_LATEST: "19.3.0",
30+
},
31+
});
32+
33+
expect(result.status).toBe(1);
34+
expect(result.stdout).toBe("");
35+
expect(result.stderr).toContain(
36+
"React 19.3.0 has been released on npm, but this repository currently supports only React 19.0.x through 19.2.x.",
37+
);
38+
expect(result.stderr).toContain("Current peerDependencies.react: ^19.0.0");
39+
expect(result.stderr).toContain("Configured supportedReactRange: >=19.0.0 <19.3.0");
40+
expect(result.stderr).toContain("Current derived React ceiling: <19.3.0");
41+
});
42+
});

0 commit comments

Comments
 (0)