-
Notifications
You must be signed in to change notification settings - Fork 64
feat: release schedule #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
c7f0ae6
0ebb7e6
f46447c
ca86397
1b1ddeb
9ad78c8
5e50eb5
674907a
c7350ee
99b5972
5d0b3cc
d428327
d61fa84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| }, | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| import { parse as parseSemver } from 'semver'; | ||
| import memoize from '@keyvhq/memoize'; | ||
| import { ElectronRelease, getReleasesOrUpdate } from './release-data'; | ||
| import { extractChromiumMilestone, extractNodeVersion, 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; // `${major}.${minor}` | ||
| status: 'active' | 'prerelease' | '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([ | ||
dsanders11 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| [ | ||
| '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', | ||
| }, | ||
| ], | ||
clavin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ]); | ||
|
|
||
| // 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 latestRelease = majorGroups.get(major)!.releases[0]; | ||
| const nodeVersion = extractNodeVersion(latestRelease.node); | ||
|
|
||
| const entry: AbsoluteMajorReleaseSchedule = { | ||
| version: `${major}.0.0`, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For discussion: do we really want the 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).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Node.js version shown is for the You make a good point! Here's how I see it
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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah, I misread the code, you're right. With that in mind, I'm fine leaving it with the Alternatively, we could make the suffix |
||
| alphaDate, | ||
| betaDate: chromiumSchedule.earliestBeta, | ||
| stableDate: chromiumSchedule.stableDate, | ||
| chromiumVersion: milestone, | ||
| nodeVersion, | ||
| 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'), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once we expose the data in JSON via
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| { | ||
| // 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); | ||
| const status = | ||
| major > latestStableMajor ? 'prerelease' : major >= minActiveMajor ? 'active' : '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; | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| 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 Node.js major.minor version from a Node version string. | ||
| * @param nodeString - Node version string like "20.11.1" or "22.14.0" | ||
| * @returns Node version in "major.minor" format like "20.11" | ||
| */ | ||
| export function extractNodeVersion(nodeString: string): string { | ||
| const parts = nodeString.split('.'); | ||
| if (parts.length < 2) { | ||
| throw new Error(`Invalid Node.js version string: ${nodeString}`); | ||
| } | ||
| return `${parts[0]}.${parts[1]}`; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"> | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.