Skip to content

Commit 31bda23

Browse files
committed
react release guard
1 parent b4a9dce commit 31bda23

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ 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

.github/workflows/nightly.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,22 @@ 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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
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",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import packageJson from "../package.json" with { type: "json" };
2+
3+
function parseSemver(version) {
4+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
5+
6+
if (!match) {
7+
throw new Error(`Unsupported semver format: ${version}`);
8+
}
9+
10+
return {
11+
major: Number(match[1]),
12+
minor: Number(match[2]),
13+
patch: Number(match[3]),
14+
};
15+
}
16+
17+
function compareSemver(left, right) {
18+
if (left.major !== right.major) {
19+
return left.major - right.major;
20+
}
21+
22+
if (left.minor !== right.minor) {
23+
return left.minor - right.minor;
24+
}
25+
26+
return left.patch - right.patch;
27+
}
28+
29+
function incrementCaretUpperBound(version) {
30+
if (version.major > 0) {
31+
return {
32+
major: version.major + 1,
33+
minor: 0,
34+
patch: 0,
35+
};
36+
}
37+
38+
return {
39+
major: 0,
40+
minor: version.minor + 1,
41+
patch: 0,
42+
};
43+
}
44+
45+
function incrementTildeUpperBound(version) {
46+
return {
47+
major: version.major,
48+
minor: version.minor + 1,
49+
patch: 0,
50+
};
51+
}
52+
53+
function parseExclusiveUpperBounds(range) {
54+
const upperBounds = [];
55+
const lessThanMatches = range.matchAll(/<\s*(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
56+
57+
for (const match of lessThanMatches) {
58+
upperBounds.push(parseSemver(match[1]));
59+
}
60+
61+
const caretMatches = range.matchAll(/\^(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
62+
for (const match of caretMatches) {
63+
upperBounds.push(incrementCaretUpperBound(parseSemver(match[1])));
64+
}
65+
66+
const tildeMatches = range.matchAll(/~(\d+\.\d+\.\d+(?:[-+][^\s|]+)?)/g);
67+
for (const match of tildeMatches) {
68+
upperBounds.push(incrementTildeUpperBound(parseSemver(match[1])));
69+
}
70+
71+
return upperBounds;
72+
}
73+
74+
function getReactSupportWindow(range) {
75+
const upperBounds = parseExclusiveUpperBounds(range);
76+
77+
if (upperBounds.length === 0) {
78+
throw new Error(
79+
`Could not derive a supported React ceiling from peerDependencies.react: ${range}`,
80+
);
81+
}
82+
83+
return upperBounds.reduce((lowest, candidate) =>
84+
compareSemver(candidate, lowest) < 0 ? candidate : lowest,
85+
);
86+
}
87+
88+
function formatSemver(version) {
89+
return `${version.major}.${version.minor}.${version.patch}`;
90+
}
91+
92+
function getSupportedLineLabel(upperBound) {
93+
if (upperBound.patch === 0 && upperBound.minor > 0) {
94+
return `${upperBound.major}.${upperBound.minor - 1}.x`;
95+
}
96+
97+
if (upperBound.minor === 0 && upperBound.patch === 0) {
98+
return `${upperBound.major - 1}.x`;
99+
}
100+
101+
return `<${formatSemver(upperBound)}`;
102+
}
103+
104+
async function getLatestReactVersion() {
105+
const overriddenVersion = process.env.REACT_RELEASE_GUARD_LATEST;
106+
107+
if (overriddenVersion) {
108+
return overriddenVersion;
109+
}
110+
111+
const response = await fetch("https://registry.npmjs.org/react");
112+
113+
if (!response.ok) {
114+
throw new Error(
115+
`Failed to fetch react package metadata: ${response.status} ${response.statusText}`,
116+
);
117+
}
118+
119+
const metadata = await response.json();
120+
const latestVersion = metadata?.["dist-tags"]?.latest;
121+
122+
if (typeof latestVersion !== "string") {
123+
throw new Error("Missing dist-tags.latest in react package metadata");
124+
}
125+
126+
return latestVersion;
127+
}
128+
129+
async function main() {
130+
const latestVersion = await getLatestReactVersion();
131+
const parsedLatest = parseSemver(latestVersion);
132+
const peerRange = packageJson.peerDependencies?.react ?? "<missing>";
133+
const upperBound = getReactSupportWindow(peerRange);
134+
const supportedLineLabel = getSupportedLineLabel(upperBound);
135+
136+
if (compareSemver(parsedLatest, upperBound) < 0) {
137+
console.log(`React ${latestVersion} is still within the supported line (${supportedLineLabel}).`);
138+
return;
139+
}
140+
141+
const failureLines = [
142+
`React ${latestVersion} has been released on npm, but this repository only supports up to React ${supportedLineLabel}.`,
143+
`A plain npm install of test-renderer is no longer safely constrained for the newest React release.`,
144+
`Current peerDependencies.react: ${peerRange}`,
145+
`Current derived React ceiling: <${formatSemver(upperBound)}`,
146+
`Update the compatibility policy, add support for the new React line, and tighten the published peer dependency range before relying on latest again.`,
147+
];
148+
149+
throw new Error(failureLines.join("\n"));
150+
}
151+
152+
main().catch((error) => {
153+
console.error(error instanceof Error ? error.message : error);
154+
process.exit(1);
155+
});

0 commit comments

Comments
 (0)