Skip to content
Merged
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
52 changes: 29 additions & 23 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,27 @@ updates:
interval: weekly
day: monday
time: '06:00'
timezone: America/Chicago
open-pull-requests-limit: 10
timezone: America/New_York
cooldown:
default-days: 3
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
open-pull-requests-limit: 1
target-branch: dev
# Dependabot scopes dev-only groups as chore(deps-dev), which intentionally does not release.
commit-message:
prefix: chore
include: scope
labels:
- dependencies
- automated
groups:
production-minor-patch-dependencies:
dependency-type: production
update-types:
- minor
- patch
exclude-patterns:
- react
- react-dom

development-minor-patch-dependencies:
dependency-type: development
update-types:
- minor
- patch
exclude-patterns:
- react
- react-dom
- '@types/react'
- '@types/react-dom'
npm-dependencies:
patterns:
- '*'

# React major upgrades are owned by the React compatibility watcher.
ignore:
- dependency-name: react
update-types:
Expand All @@ -50,15 +41,26 @@ updates:
- dependency-name: '@types/react-dom'
update-types:
- version-update:semver-major
# Remove once eslint-plugin-react and eslint-plugin-jsx-a11y support ESLint 10.
- dependency-name: '@eslint/js'
versions:
- '>=10.0.0'
- dependency-name: eslint
versions:
- '>=10.0.0'
# Remove once typescript-eslint supports TypeScript >=6.1.0.
- dependency-name: typescript
versions:
- '>=6.1.0'

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: '06:30'
timezone: America/Chicago
open-pull-requests-limit: 10
timezone: America/New_York
open-pull-requests-limit: 1
target-branch: dev
commit-message:
prefix: ci
Expand All @@ -67,3 +69,7 @@ updates:
- dependencies
- github-actions
- automated
groups:
github-actions:
patterns:
- '*'
13 changes: 8 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Describe the change clearly and concisely.

> This repository squash merges, so the title above becomes the commit subject and drives release automation. Use a conventional title such as `fix: correct navigation index underflow`.

## Why This Change

Explain the problem being solved or the reason for the change.
Expand Down Expand Up @@ -52,11 +54,11 @@ If this is breaking, describe the change and required migration steps.

What did you do to validate this change?

- [ ] `pnpm run format`
- [ ] `pnpm run lint`
- [ ] `pnpm run typecheck`
- [ ] `pnpm run test`
- [ ] `pnpm run build`
- [ ] `pnpm run typecheck`
- [ ] `pnpm test`
- [ ] `pnpm run lint`
- [ ] `pnpm run format:check`

Describe any additional manual or automated testing performed.

Expand All @@ -65,7 +67,8 @@ Describe any additional manual or automated testing performed.
- [ ] No documentation update needed
- [ ] I updated README and/or docs
- [ ] I updated examples
- [ ] I updated changelog/release-related content if needed

The changelog and package version are generated by release automation. Do not edit them here.

## Related Issues

Expand Down
193 changes: 193 additions & 0 deletions .github/scripts/react-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
export interface ReactSupportPackageJson {
peerDependencies?: {
react?: string;
'react-dom'?: string;
};
}

export interface ReactMajorUpdate<TPackageJson> {
candidateMajor: number;
changed: boolean;
packageJson: TPackageJson;
supportedMajors: string[];
}

export interface ReactSupportFileUpdate {
original: string;
path: string;
updated: string;
}

const peerRangePattern = /^>=(\d+) <(\d+)$/;
const compatibilityPattern = /Requires React and React DOM ([^.]+)\./g;

function getMajor(version: string): number {
const major = /^(\d+)\./.exec(version)?.[1];

if (major === undefined) {
throw new Error(`Could not parse React major from "${version}".`);
}

return Number.parseInt(major, 10);
}

function parsePeerRange(peerRange: string): {
exclusiveMaximumMajor: number;
minimumMajor: number;
} {
const [, minimum, exclusiveMaximum] = peerRangePattern.exec(peerRange) ?? [];

if (minimum === undefined || exclusiveMaximum === undefined) {
throw new Error(
`Expected a contiguous React peer range like ">=18 <20", received "${peerRange}".`,
);
}

const minimumMajor = Number.parseInt(minimum, 10);
const exclusiveMaximumMajor = Number.parseInt(exclusiveMaximum, 10);

if (exclusiveMaximumMajor <= minimumMajor) {
throw new Error(
`React peer range "${peerRange}" must include at least one major.`,
);
}

return { exclusiveMaximumMajor, minimumMajor };
}

function getMajorRange(
minimumMajor: number,
exclusiveMaximumMajor: number,
): string[] {
return Array.from(
{ length: exclusiveMaximumMajor - minimumMajor },
(_, index) => String(minimumMajor + index),
);
}

function formatCompatibility(majors: string[]): string {
const [first, ...rest] = majors;

if (first === undefined) {
throw new Error('Expected at least one supported React major.');
}

const last = rest.pop();

if (last === undefined) {
return first;
}

return rest.length === 0
? `${first} or ${last}`
: `${[first, ...rest].join(', ')}, or ${last}`;
}

export function getSupportedReactMajors(
reactRange: string,
reactDomRange: string,
): string[] {
if (reactRange !== reactDomRange) {
throw new Error(
`React peer ranges must match: react is "${reactRange}" and react-dom is "${reactDomRange}".`,
);
}

const { exclusiveMaximumMajor, minimumMajor } = parsePeerRange(reactRange);

return getMajorRange(minimumMajor, exclusiveMaximumMajor);
}

export function createReactMajorUpdate<
TPackageJson extends ReactSupportPackageJson,
>(
packageJson: TPackageJson,
latestVersion: string,
): ReactMajorUpdate<TPackageJson> {
const reactRange = packageJson.peerDependencies?.react;
const reactDomRange = packageJson.peerDependencies?.['react-dom'];

if (!reactRange || !reactDomRange) {
throw new Error(
'package.json must define react and react-dom peer dependencies.',
);
}

const supportedMajors = getSupportedReactMajors(reactRange, reactDomRange);
const highestSupportedMajor = supportedMajors[supportedMajors.length - 1];

if (highestSupportedMajor === undefined) {
throw new Error('The React peer range produced no supported majors.');
}

const maximumSupportedMajor = Number.parseInt(highestSupportedMajor, 10);
const latestMajor = getMajor(latestVersion);

if (latestMajor <= maximumSupportedMajor) {
return {
candidateMajor: maximumSupportedMajor,
changed: false,
packageJson,
supportedMajors,
};
}

const candidateMajor = maximumSupportedMajor + 1;
const minimumMajor = supportedMajors[0];
const nextPeerRange = `>=${minimumMajor} <${candidateMajor + 1}`;
const updatedPackageJson = structuredClone(packageJson);

if (!updatedPackageJson.peerDependencies) {
throw new Error(
'package.json must define react and react-dom peer dependencies.',
);
}

updatedPackageJson.peerDependencies.react = nextPeerRange;
updatedPackageJson.peerDependencies['react-dom'] = nextPeerRange;

return {
candidateMajor,
changed: true,
packageJson: updatedPackageJson,
supportedMajors: [...supportedMajors, String(candidateMajor)],
};
}

export function updateCompatibilityText(
readme: string,
supportedMajors: string[],
): string {
const matches = [...readme.matchAll(compatibilityPattern)];

if (matches.length !== 1) {
throw new Error(
`Expected exactly one React compatibility sentence, found ${matches.length}.`,
);
}

return readme.replace(
compatibilityPattern,
`Requires React and React DOM ${formatCompatibility(supportedMajors)}.`,
);
}

export function writeReactSupportFiles(
updates: ReactSupportFileUpdate[],
writeFile: (path: string, value: string) => void,
): void {
const completedUpdates: ReactSupportFileUpdate[] = [];

try {
updates.forEach((update) => {
writeFile(update.path, update.updated);
completedUpdates.push(update);
});
} catch (error) {
completedUpdates.reverse().forEach((update) => {
writeFile(update.path, update.original);
});

throw error;
}
}
Loading
Loading