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
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,
},
);
306 changes: 306 additions & 0 deletions app/data/release-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
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
}

// Schedule overrides for dates that deviate from calculated estimates:
// - v2-v5: Pre-Chromium alignment era (before standardized release cadence afaik)
// - v6-v14: Transition to modern release process
// - v15: Introduction of alpha releases
// - v16+: Minor adjustments from Chromium schedule predictions
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',
betaDate: '2021-09-01',
},
],
[
'16.0.0',
{
betaDate: '2021-10-20',
},
],
[
'22.0.0',
{
// Policy exception: extended EOL to support extended end-of-life for Windows 7/8/8.1
eolDate: '2023-10-10',
},
],
[
'28.0.0',
{
alphaDate: '2023-10-11',
betaDate: '2023-11-06',
},
],
[
'32.0.0',
{
alphaDate: '2024-06-14',
},
],
]);

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

const offsetDays = (dateStr: string, days: number): string => {
const date = new Date(dateStr + 'T00:00:00');
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
};

/**
* 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 = new Map<number, AbsoluteMajorReleaseSchedule>();

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

// Alpha/Beta pattern:
// | ------- | ------------------ | ------------------------- |
// | Version | Alpha | Beta |
// | ------- | ------------------ | ------------------------- |
// | v2-5 | None | History (overrides) |
// | v6-14 | None | Prev stable + 2 days |
// | v15+ | Prev stable + 2 | Chromium dates + offset |
// | ------- | ------------------ | ------------------------- |
let alphaDate: string | null = null;
let betaDate: string;
if (major < 6) {
// (no alpha)
betaDate = ''; // Will be set by override
} else {
const prevStablePlus2 = offsetDays(schedule.get(major - 1)!.stableDate, 2);

if (major < 15) {
// (no alpha)
betaDate = prevStablePlus2;
} else {
alphaDate = prevStablePlus2;

// Chromium beta offset pattern:
// - M113 and below: beta on Thursdays, offset -2 to Tuesday
// - M114 and above: beta on Wednesdays, offset -1 to Tuesday
const betaOffset = milestone <= 113 ? -2 : -1;
betaDate = offsetDays(chromiumSchedule.earliestBeta, betaOffset);
}
}

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,
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.set(major, entry);
}

// Calculate EOL dates
for (const entry of schedule.values()) {
if (entry.eolDate !== '') {
// Already set via override
continue;
}

const major = parseInt(entry.version.split('.')[0], 10);
const eolMajor = major + getSupportWindow(major);
const eolEntry = schedule.get(eolMajor);

if (eolEntry) {
entry.eolDate = eolEntry.stableDate;
} else {
// Extrapolate for future versions
const maxMajor = Math.max(...Array.from(schedule.keys()));
const maxEntry = schedule.get(maxMajor)!;
const milestone = maxEntry.chromiumVersion + (eolMajor - maxMajor) * 2; // 2 milestones per major
const eolSchedule = await getMilestoneSchedule(milestone);
entry.eolDate = eolSchedule.stableDate;
}
}

// NB: `Map.values()` iterates in insertion order (ascending major)
return Array.from(schedule.values());
},
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 2 hours
ttl: 2 * 60 * 60 * 1000,
// At 10 mineutes, refetch but serve stale data
staleTtl: 10 * 60 * 1000,
},
);

/**
* 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;
}
Loading