Skip to content

Commit 1b3fb43

Browse files
authored
feat: release schedule (#195)
* Remove redundant border on history page * Add schedule page, data fetching, caching, and estimation logic * Remove unclear `+` indicator, adjust disclaimer wording instead * Simplify release-schedule.ts * Use full semver for Node.js version * Link to Chrome & Node.js release notes * Add unique background patterns for rows by status * Unify text size in rows * Simplify styles * Add nightly and rename 'active to 'stable' * Add EOL override for E22 * Simplify, improve accuracy, add overrides Now correctly predicts alpha/beta dates for most versions. Deliberately corrects some beta release dates that were incorrectly published as Wednesday instead of Tuesday.
1 parent 855343d commit 1b3fb43

File tree

8 files changed

+705
-2
lines changed

8 files changed

+705
-2
lines changed

app/data/dash/chromium-schedule.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import memoize from '@keyvhq/memoize';
2+
import { getKeyvCache } from '../cache';
3+
4+
export interface ChromiumMilestoneSchedule {
5+
earliestBeta: string; // YYYY-MM-DD
6+
stableDate: string; // YYYY-MM-DD
7+
}
8+
9+
interface ChromiumDashResponse {
10+
mstones: Array<{
11+
earliest_beta: string; // ISO timestamp
12+
stable_date: string; // ISO timestamp
13+
mstone: number;
14+
}>;
15+
}
16+
17+
/**
18+
* Fetch Chromium milestone schedule from chromiumdash API.
19+
* @param milestone - Chromium milestone number (e.g., 140)
20+
* @returns Object with earliestBeta and stableDate in YYYY-MM-DD format
21+
*/
22+
export const getMilestoneSchedule = memoize(
23+
async (milestone: number): Promise<ChromiumMilestoneSchedule> => {
24+
const response = await fetch(
25+
`https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${milestone}`,
26+
);
27+
28+
if (!response.ok) {
29+
throw new Error(
30+
`Failed to fetch Chromium schedule for milestone ${milestone}: ${response.status}`,
31+
);
32+
}
33+
34+
const data = (await response.json()) as ChromiumDashResponse;
35+
36+
if (!data.mstones || !Array.isArray(data.mstones) || data.mstones.length === 0) {
37+
throw new Error(`No schedule data found for Chromium milestone ${milestone}`);
38+
}
39+
40+
const schedule = data.mstones[0];
41+
42+
return {
43+
earliestBeta: schedule.earliest_beta.split('T')[0],
44+
stableDate: schedule.stable_date.split('T')[0],
45+
};
46+
},
47+
getKeyvCache('chromium-schedule'),
48+
{
49+
// Cache for 6 hours
50+
ttl: 6 * 60 * 60 * 1_000,
51+
// At 5 hours, refetch but serve stale data
52+
staleTtl: 60 * 60 * 1_000,
53+
},
54+
);

app/data/release-schedule.ts

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { parse as parseSemver } from 'semver';
2+
import memoize from '@keyvhq/memoize';
3+
import { ElectronRelease, getReleasesOrUpdate } from './release-data';
4+
import { extractChromiumMilestone, getPrereleaseType } from '~/helpers/version';
5+
import { getMilestoneSchedule } from './dash/chromium-schedule';
6+
import { getKeyvCache } from './cache';
7+
8+
export interface MajorReleaseSchedule {
9+
version: string; // `${major}.0.0`
10+
alphaDate: string | null; // YYYY-MM-DD -- some old versions didn't have alpha releases
11+
betaDate: string; // YYYY-MM-DD
12+
stableDate: string; // YYYY-MM-DD
13+
eolDate: string; // YYYY-MM-DD
14+
chromiumVersion: number; // milestone, aka major version
15+
nodeVersion: string; // full semver
16+
status: 'stable' | 'prerelease' | 'nightly' | 'eol';
17+
}
18+
19+
type AbsoluteMajorReleaseSchedule = Omit<MajorReleaseSchedule, 'status'>;
20+
21+
interface MajorReleaseGroup {
22+
major: number;
23+
releases: ElectronRelease[];
24+
firstStable?: ElectronRelease; // Only used for Chromium milestone extraction
25+
}
26+
27+
// Schedule overrides for dates that deviate from calculated estimates:
28+
// - v2-v5: Pre-Chromium alignment era (before standardized release cadence afaik)
29+
// - v6-v14: Transition to modern release process
30+
// - v15: Introduction of alpha releases
31+
// - v16+: Minor adjustments from Chromium schedule predictions
32+
const SCHEDULE_OVERRIDES: Map<string, Partial<AbsoluteMajorReleaseSchedule>> = new Map([
33+
[
34+
'2.0.0',
35+
{
36+
betaDate: '2018-02-21',
37+
stableDate: '2018-05-01',
38+
},
39+
],
40+
[
41+
'3.0.0',
42+
{
43+
betaDate: '2018-06-21',
44+
stableDate: '2018-09-18',
45+
},
46+
],
47+
[
48+
'4.0.0',
49+
{
50+
betaDate: '2018-10-11',
51+
stableDate: '2018-12-20',
52+
},
53+
],
54+
[
55+
'5.0.0',
56+
{
57+
betaDate: '2019-01-22',
58+
stableDate: '2019-04-23',
59+
},
60+
],
61+
[
62+
'6.0.0',
63+
{
64+
betaDate: '2019-04-25',
65+
},
66+
],
67+
[
68+
'15.0.0',
69+
{
70+
alphaDate: '2021-07-20',
71+
betaDate: '2021-09-01',
72+
},
73+
],
74+
[
75+
'16.0.0',
76+
{
77+
betaDate: '2021-10-20',
78+
},
79+
],
80+
[
81+
'22.0.0',
82+
{
83+
// Policy exception: extended EOL to support extended end-of-life for Windows 7/8/8.1
84+
eolDate: '2023-10-10',
85+
},
86+
],
87+
[
88+
'28.0.0',
89+
{
90+
alphaDate: '2023-10-11',
91+
betaDate: '2023-11-06',
92+
},
93+
],
94+
[
95+
'32.0.0',
96+
{
97+
alphaDate: '2024-06-14',
98+
},
99+
],
100+
]);
101+
102+
// Determine support window: 4 for v12-15, 3 for the rest
103+
const getSupportWindow = (major: number): number => {
104+
return major >= 12 && major <= 15 ? 4 : 3;
105+
};
106+
107+
const offsetDays = (dateStr: string, days: number): string => {
108+
const date = new Date(dateStr + 'T00:00:00');
109+
date.setDate(date.getDate() + days);
110+
return date.toISOString().split('T')[0];
111+
};
112+
113+
/**
114+
* Get absolute schedule data (cacheable, not time-dependent).
115+
*/
116+
export const getAbsoluteSchedule = memoize(
117+
async (): Promise<AbsoluteMajorReleaseSchedule[]> => {
118+
const allReleases = await getReleasesOrUpdate();
119+
120+
// Group releases by major version (filter to >= 2)
121+
const majorGroups = new Map<number, MajorReleaseGroup>();
122+
123+
for (const release of allReleases) {
124+
const major = parseSemver(release.version)?.major;
125+
if (!major || major < 2) continue;
126+
127+
if (!majorGroups.has(major)) {
128+
majorGroups.set(major, { major, releases: [] });
129+
}
130+
131+
const group = majorGroups.get(major)!;
132+
group.releases.push(release);
133+
134+
const prereleaseType = getPrereleaseType(release.version);
135+
136+
// Track first stable release (last in iteration = first chronologically)
137+
// Only used for extracting Chromium milestone
138+
if (prereleaseType === 'stable') {
139+
group.firstStable = release;
140+
}
141+
}
142+
143+
// Build milestone map in forward pass
144+
const milestoneMap = new Map<number, number>();
145+
const sortedMajors = Array.from(majorGroups.keys()).sort((a, b) => a - b);
146+
147+
for (const major of sortedMajors) {
148+
const group = majorGroups.get(major)!;
149+
150+
if (group.firstStable) {
151+
// Use actual Chromium version from stable release
152+
const milestone = extractChromiumMilestone(group.firstStable.chrome);
153+
milestoneMap.set(major, milestone);
154+
} else {
155+
// Estimate: M(V) = M(V-1) + 2
156+
const prevMajor = major - 1;
157+
const prevMilestone = milestoneMap.get(prevMajor);
158+
159+
if (!prevMilestone) {
160+
throw new Error(
161+
`Cannot determine Chromium milestone for Electron ${major}: no stable release and no previous milestone`,
162+
);
163+
}
164+
165+
milestoneMap.set(major, prevMilestone + 2);
166+
}
167+
}
168+
169+
// Build absolute schedule data for each major
170+
const schedule = new Map<number, AbsoluteMajorReleaseSchedule>();
171+
172+
for (const major of sortedMajors) {
173+
const milestone = milestoneMap.get(major)!;
174+
const chromiumSchedule = await getMilestoneSchedule(milestone);
175+
176+
// Alpha/Beta pattern:
177+
// | ------- | ------------------ | ------------------------- |
178+
// | Version | Alpha | Beta |
179+
// | ------- | ------------------ | ------------------------- |
180+
// | v2-5 | None | History (overrides) |
181+
// | v6-14 | None | Prev stable + 2 days |
182+
// | v15+ | Prev stable + 2 | Chromium dates + offset |
183+
// | ------- | ------------------ | ------------------------- |
184+
let alphaDate: string | null = null;
185+
let betaDate: string;
186+
if (major < 6) {
187+
// (no alpha)
188+
betaDate = ''; // Will be set by override
189+
} else {
190+
const prevStablePlus2 = offsetDays(schedule.get(major - 1)!.stableDate, 2);
191+
192+
if (major < 15) {
193+
// (no alpha)
194+
betaDate = prevStablePlus2;
195+
} else {
196+
alphaDate = prevStablePlus2;
197+
198+
// Chromium beta offset pattern:
199+
// - M113 and below: beta on Thursdays, offset -2 to Tuesday
200+
// - M114 and above: beta on Wednesdays, offset -1 to Tuesday
201+
const betaOffset = milestone <= 113 ? -2 : -1;
202+
betaDate = offsetDays(chromiumSchedule.earliestBeta, betaOffset);
203+
}
204+
}
205+
206+
const group = majorGroups.get(major)!;
207+
const latestRelease = group.releases[0];
208+
209+
const entry: AbsoluteMajorReleaseSchedule = {
210+
version: `${major}.0.0`,
211+
alphaDate,
212+
betaDate,
213+
stableDate: chromiumSchedule.stableDate,
214+
chromiumVersion: milestone,
215+
nodeVersion: group.firstStable?.node ?? latestRelease.node,
216+
eolDate: '', // Placeholder, will be calculated
217+
};
218+
219+
// Apply overrides early so they cascade to dependent calculations (e.g. EOL)
220+
const override = SCHEDULE_OVERRIDES.get(entry.version);
221+
if (override) {
222+
Object.assign(entry, override);
223+
}
224+
225+
schedule.set(major, entry);
226+
}
227+
228+
// Calculate EOL dates
229+
for (const entry of schedule.values()) {
230+
if (entry.eolDate !== '') {
231+
// Already set via override
232+
continue;
233+
}
234+
235+
const major = parseInt(entry.version.split('.')[0], 10);
236+
const eolMajor = major + getSupportWindow(major);
237+
const eolEntry = schedule.get(eolMajor);
238+
239+
if (eolEntry) {
240+
entry.eolDate = eolEntry.stableDate;
241+
} else {
242+
// Extrapolate for future versions
243+
const maxMajor = Math.max(...Array.from(schedule.keys()));
244+
const maxEntry = schedule.get(maxMajor)!;
245+
const milestone = maxEntry.chromiumVersion + (eolMajor - maxMajor) * 2; // 2 milestones per major
246+
const eolSchedule = await getMilestoneSchedule(milestone);
247+
entry.eolDate = eolSchedule.stableDate;
248+
}
249+
}
250+
251+
// NB: `Map.values()` iterates in insertion order (ascending major)
252+
return Array.from(schedule.values());
253+
},
254+
getKeyvCache('absolute-schedule'),
255+
{
256+
// Cache for 2 hours
257+
ttl: 2 * 60 * 60 * 1000,
258+
// At 10 mineutes, refetch but serve stale data
259+
staleTtl: 10 * 60 * 1000,
260+
},
261+
);
262+
263+
/**
264+
* Get relative schedule data (time-dependent, includes status and EOL).
265+
*/
266+
export async function getRelativeSchedule(): Promise<MajorReleaseSchedule[]> {
267+
// Find latest major version
268+
const allReleases = await getReleasesOrUpdate();
269+
const latestStableMajor = parseInt(
270+
allReleases
271+
.find((release) => getPrereleaseType(release.version) === 'stable')
272+
?.version.split('.')[0] || '0',
273+
10,
274+
);
275+
276+
const absoluteData = await getAbsoluteSchedule();
277+
const supportWindow = getSupportWindow(latestStableMajor);
278+
const minActiveMajor = latestStableMajor - supportWindow + 1;
279+
280+
const schedule: MajorReleaseSchedule[] = absoluteData.map((entry) => {
281+
const major = parseInt(entry.version.split('.')[0], 10);
282+
283+
let status: MajorReleaseSchedule['status'];
284+
if (major > latestStableMajor) {
285+
const hasNonNightlyRelease = allReleases.find(
286+
(release) =>
287+
release.version.startsWith(`${major}.`) &&
288+
getPrereleaseType(release.version) !== 'nightly',
289+
);
290+
status = hasNonNightlyRelease ? 'prerelease' : 'nightly';
291+
} else if (major >= minActiveMajor) {
292+
status = 'stable';
293+
} else {
294+
status = 'eol';
295+
}
296+
297+
return { ...entry, status };
298+
});
299+
300+
// Sort descending by major version
301+
return schedule.sort((a, b) => {
302+
const aMajor = parseInt(a.version.split('.')[0], 10);
303+
const bMajor = parseInt(b.version.split('.')[0], 10);
304+
return bMajor - aMajor;
305+
});
306+
}

app/helpers/version.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { parse as parseSemver } from 'semver';
2+
3+
/**
4+
* Get the prerelease type from a version string.
5+
* @returns 'alpha', 'beta', 'nightly', 'stable', or undefined for unknown prerelease types
6+
*/
7+
export function getPrereleaseType(
8+
version: string,
9+
): 'alpha' | 'beta' | 'nightly' | 'stable' | undefined {
10+
const parsed = parseSemver(version);
11+
if (!parsed || parsed.prerelease.length === 0) {
12+
return 'stable';
13+
}
14+
const prereleaseType = parsed.prerelease[0];
15+
if (prereleaseType === 'alpha' || prereleaseType === 'beta' || prereleaseType === 'nightly') {
16+
return prereleaseType;
17+
}
18+
// Silently ignore unknown prerelease types
19+
return undefined;
20+
}
21+
22+
/**
23+
* Extract Chromium milestone (major version) from a Chrome version string.
24+
* @param chromeString - Chrome version string like "116.0.5845.190"
25+
* @returns Chromium milestone like 116
26+
*/
27+
export function extractChromiumMilestone(chromeString: string): number {
28+
const milestone = parseInt(chromeString.split('.')[0], 10);
29+
if (isNaN(milestone)) {
30+
throw new Error(`Invalid Chrome version string: ${chromeString}`);
31+
}
32+
return milestone;
33+
}

0 commit comments

Comments
 (0)