Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
54 changes: 54 additions & 0 deletions app/data/dash/chromium-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import memoize from '@keyvhq/memoize';
import { getKeyvCache } from '../cache';

export interface ChromiumMilestoneSchedule {
earliestBeta: string; // YYYY-MM-DD
stableDate: string; // YYYY-MM-DD
}

interface ChromiumDashResponse {
mstones: Array<{
earliest_beta: string; // ISO timestamp
stable_date: string; // ISO timestamp
mstone: number;
}>;
}

/**
* Fetch Chromium milestone schedule from chromiumdash API.
* @param milestone - Chromium milestone number (e.g., 140)
* @returns Object with earliestBeta and stableDate in YYYY-MM-DD format
*/
export const getMilestoneSchedule = memoize(
async (milestone: number): Promise<ChromiumMilestoneSchedule> => {
const response = await fetch(
`https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${milestone}`,
);

if (!response.ok) {
throw new Error(
`Failed to fetch Chromium schedule for milestone ${milestone}: ${response.status}`,
);
}

const data = (await response.json()) as ChromiumDashResponse;

if (!data.mstones || !Array.isArray(data.mstones) || data.mstones.length === 0) {
throw new Error(`No schedule data found for Chromium milestone ${milestone}`);
}

const schedule = data.mstones[0];

return {
earliestBeta: schedule.earliest_beta.split('T')[0],
stableDate: schedule.stable_date.split('T')[0],
};
},
getKeyvCache('chromium-schedule'),
{
// Cache for 6 hours
ttl: 6 * 60 * 60 * 1_000,
// At 5 hours, refetch but serve stale data
staleTtl: 60 * 60 * 1_000,
},
);
244 changes: 244 additions & 0 deletions app/data/release-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { parse as parseSemver } from 'semver';
import memoize from '@keyvhq/memoize';
import { ElectronRelease, getReleasesOrUpdate } from './release-data';
import { extractChromiumMilestone, getPrereleaseType } from '~/helpers/version';
import { getMilestoneSchedule } from './dash/chromium-schedule';
import { getKeyvCache } from './cache';

export interface MajorReleaseSchedule {
version: string; // `${major}.0.0`
alphaDate: string | null; // YYYY-MM-DD -- some old versions didn't have alpha releases
betaDate: string; // YYYY-MM-DD
stableDate: string; // YYYY-MM-DD
eolDate: string; // YYYY-MM-DD
chromiumVersion: number; // milestone, aka major version
nodeVersion: string; // full semver
status: 'stable' | 'prerelease' | 'nightly' | 'eol';
}

type AbsoluteMajorReleaseSchedule = Omit<MajorReleaseSchedule, 'status'>;

interface MajorReleaseGroup {
major: number;
releases: ElectronRelease[];
firstStable?: ElectronRelease; // Only used for Chromium milestone extraction
}

const SCHEDULE_OVERRIDES: Map<string, Partial<AbsoluteMajorReleaseSchedule>> = new Map([
[
'2.0.0',
{
betaDate: '2018-02-21',
stableDate: '2018-05-01',
},
],
[
'3.0.0',
{
betaDate: '2018-06-21',
stableDate: '2018-09-18',
},
],
[
'4.0.0',
{
betaDate: '2018-10-11',
stableDate: '2018-12-20',
},
],
[
'5.0.0',
{
betaDate: '2019-01-22',
stableDate: '2019-04-23',
},
],
[
'6.0.0',
{
betaDate: '2019-04-25',
},
],
[
'15.0.0',
{
alphaDate: '2021-07-20',
},
],
]);

// Determine support window: 4 for v12-15, 3 for the rest
const getSupportWindow = (major: number): number => {
return major >= 12 && major <= 15 ? 4 : 3;
};

/**
* Get absolute schedule data (cacheable, not time-dependent).
*/
export const getAbsoluteSchedule = memoize(
async (): Promise<AbsoluteMajorReleaseSchedule[]> => {
const allReleases = await getReleasesOrUpdate();

// Group releases by major version (filter to >= 2)
const majorGroups = new Map<number, MajorReleaseGroup>();

for (const release of allReleases) {
const major = parseSemver(release.version)?.major;
if (!major || major < 2) continue;

if (!majorGroups.has(major)) {
majorGroups.set(major, { major, releases: [] });
}

const group = majorGroups.get(major)!;
group.releases.push(release);

const prereleaseType = getPrereleaseType(release.version);

// Track first stable release (last in iteration = first chronologically)
// Only used for extracting Chromium milestone
if (prereleaseType === 'stable') {
group.firstStable = release;
}
}

// Build milestone map in forward pass
const milestoneMap = new Map<number, number>();
const sortedMajors = Array.from(majorGroups.keys()).sort((a, b) => a - b);

for (const major of sortedMajors) {
const group = majorGroups.get(major)!;

if (group.firstStable) {
// Use actual Chromium version from stable release
const milestone = extractChromiumMilestone(group.firstStable.chrome);
milestoneMap.set(major, milestone);
} else {
// Estimate: M(V) = M(V-1) + 2
const prevMajor = major - 1;
const prevMilestone = milestoneMap.get(prevMajor);

if (!prevMilestone) {
throw new Error(
`Cannot determine Chromium milestone for Electron ${major}: no stable release and no previous milestone`,
);
}

milestoneMap.set(major, prevMilestone + 2);
}
}

// Build absolute schedule data for each major
const schedule: AbsoluteMajorReleaseSchedule[] = [];

for (const major of sortedMajors) {
const milestone = milestoneMap.get(major)!;
const chromiumSchedule = await getMilestoneSchedule(milestone);

// Alpha: previous major's stable + 2 days (null for v14 and earlier)
let alphaDate: string | null = null;
if (major > 14) {
const prevMilestone = milestoneMap.get(major - 1)!;
const prevSchedule = await getMilestoneSchedule(prevMilestone);

// Add 2 days to previous stable date
const prevStable = new Date(prevSchedule.stableDate + 'T00:00:00');
prevStable.setDate(prevStable.getDate() + 2);
alphaDate = prevStable.toISOString().split('T')[0];
}

const group = majorGroups.get(major)!;
const latestRelease = group.releases[0];

const entry: AbsoluteMajorReleaseSchedule = {
version: `${major}.0.0`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For discussion: do we really want the .0.0 suffix here, or just the major? Since the Node.js version shown is for the latest release in a major line, it's a bit misleading to show that Node.js version and the .0.0 version number, since that might cause confusion.

I'd be in favor of dropping the suffix and just going with the major numbers, personally. I don't think the static suffix on all of them adds any value (I know this is how we have it in the Markdown at the moment).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Node.js version shown is for the *.0.0 release—unless there are no stable releases yet, in which case it uses the latest pre-release for that line.

You make a good point! Here's how I see it

  • In favor of keeping the suffix: it gives each row’s major “key” a strong visual anchor and helps clarify the nature of the data.
  • Against it: it adds noise and slightly constrains what we put in the table.

Not picky about which direction we take--just wanted to offer that perspective before changing it. With that context are you still leaning toward dropping the suffix?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Node.js version shown is for the *.0.0 release—unless there are no stable releases yet, in which case it uses the latest pre-release for that line.

Ah, I misread the code, you're right. With that in mind, I'm fine leaving it with the .0.0 suffix for now.

Alternatively, we could make the suffix .x.y which would leave the visual anchor, but avoid any confusion about what info applies to the whole major line versus just the .0.0 version.

alphaDate,
betaDate: chromiumSchedule.earliestBeta,
stableDate: chromiumSchedule.stableDate,
chromiumVersion: milestone,
nodeVersion: group.firstStable?.node ?? latestRelease.node,
eolDate: '', // Placeholder, will be calculated
};

// Apply overrides early so they cascade to dependent calculations (e.g. EOL)
const override = SCHEDULE_OVERRIDES.get(entry.version);
if (override) {
Object.assign(entry, override);
}

schedule.push(entry);
}

// Calculate EOL dates
for (const entry of schedule) {
const major = parseInt(entry.version.split('.')[0], 10);
const eolMajor = major + getSupportWindow(major);
const eolEntry = schedule.find((r) => r.version === `${eolMajor}.0.0`);

if (eolEntry) {
entry.eolDate = eolEntry.stableDate;
} else {
// Extrapolate for future versions
const maxMajor = Math.max(...schedule.map((r) => parseInt(r.version.split('.')[0], 10)));
const maxEntry = schedule.find((r) => r.version === `${maxMajor}.0.0`)!;
const milestone = maxEntry.chromiumVersion + (eolMajor - maxMajor) * 2; // 2 milestones per major
const eolSchedule = await getMilestoneSchedule(milestone);
entry.eolDate = eolSchedule.stableDate;
}
}

return schedule;
},
getKeyvCache('absolute-schedule'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we had a way to cache this data more permanently, rather than being destined to recalculate the dates for old releases for the rest of time.

Maybe we could put the historical data into a JSON file in this repo, and this code could stop when it hits a major that's already in the committed historical data? Then it would still be dynamic, but occasionally one of us could update the historical data in the JSON file and save some CPU cycles on recalculating dates that shouldn't change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we expose the data in JSON via /schedule.json, we could even have a scheduled workflow that automatically opens a PR to update the historical data JSON file once a release has passed EOL and the data will no longer change. Nice little feedback loop could happen there. 🙂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a big fan of the model you're proposing over the one I implemented in this PR. We should definitely implement that system, as the schedule.json file would be very useful for other applications/systems.

{
// Cache for 60 seconds
ttl: 60_000,
// At 10 seconds, refetch but serve stale data
staleTtl: 10_000,
},
);

/**
* Get relative schedule data (time-dependent, includes status and EOL).
*/
export async function getRelativeSchedule(): Promise<MajorReleaseSchedule[]> {
// Find latest major version
const allReleases = await getReleasesOrUpdate();
const latestStableMajor = parseInt(
allReleases
.find((release) => getPrereleaseType(release.version) === 'stable')
?.version.split('.')[0] || '0',
10,
);

const absoluteData = await getAbsoluteSchedule();
const supportWindow = getSupportWindow(latestStableMajor);
const minActiveMajor = latestStableMajor - supportWindow + 1;

const schedule: MajorReleaseSchedule[] = absoluteData.map((entry) => {
const major = parseInt(entry.version.split('.')[0], 10);

let status: MajorReleaseSchedule['status'];
if (major > latestStableMajor) {
const hasNonNightlyRelease = allReleases.find(
(release) =>
release.version.startsWith(`${major}.`) &&
getPrereleaseType(release.version) !== 'nightly',
);
status = hasNonNightlyRelease ? 'prerelease' : 'nightly';
} else if (major >= minActiveMajor) {
status = 'stable';
} else {
status = 'eol';
}

return { ...entry, status };
});

// Sort descending by major version
return schedule.sort((a, b) => {
const aMajor = parseInt(a.version.split('.')[0], 10);
const bMajor = parseInt(b.version.split('.')[0], 10);
return bMajor - aMajor;
});
}
33 changes: 33 additions & 0 deletions app/helpers/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { parse as parseSemver } from 'semver';

/**
* Get the prerelease type from a version string.
* @returns 'alpha', 'beta', 'nightly', 'stable', or undefined for unknown prerelease types
*/
export function getPrereleaseType(
version: string,
): 'alpha' | 'beta' | 'nightly' | 'stable' | undefined {
const parsed = parseSemver(version);
if (!parsed || parsed.prerelease.length === 0) {
return 'stable';
}
const prereleaseType = parsed.prerelease[0];
if (prereleaseType === 'alpha' || prereleaseType === 'beta' || prereleaseType === 'nightly') {
return prereleaseType;
}
// Silently ignore unknown prerelease types
return undefined;
}

/**
* Extract Chromium milestone (major version) from a Chrome version string.
* @param chromeString - Chrome version string like "116.0.5845.190"
* @returns Chromium milestone like 116
*/
export function extractChromiumMilestone(chromeString: string): number {
const milestone = parseInt(chromeString.split('.')[0], 10);
if (isNaN(milestone)) {
throw new Error(`Invalid Chrome version string: ${chromeString}`);
}
return milestone;
}
11 changes: 10 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { LinksFunction } from '@remix-run/node';

import './tailwind.css';
import { Logo } from '~/components/Logo';
import { ArrowUpRight, History, Search } from 'lucide-react';
import { ArrowUpRight, History, Search, CalendarPlus } from 'lucide-react';
import { useEffect } from 'react';

export const links: LinksFunction = () => [];
Expand All @@ -22,6 +22,15 @@ const nav = [
),
path: '/history',
},
{
title: (
<>
<CalendarPlus className="w-4 h-4" />
Schedule
</>
),
path: '/schedule',
},
{
title: (
<>
Expand Down
1 change: 1 addition & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export default [
route('compare/:fromVersion/:toVersion', 'routes/release/compare.tsx'),
route(':version', 'routes/release/single.tsx'),
]),
route('schedule', 'routes/schedule.tsx'),
] satisfies RouteConfig;
2 changes: 1 addition & 1 deletion app/routes/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export default function ReleaseHistory() {
</div>

<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-wrap items-center gap-6">
<div className="p-4 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-wrap items-center gap-6">
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by touchup: this bottom border was redundant. (Looking again, I think the colors might be too? But at least it doesn't show now.)

<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-sm text-gray-700 dark:text-gray-300">Stable Release</span>
Expand Down
Loading