mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(updater): nightly builds and a stable/nightly update channel (#1608)
* feat(updater): nightly builds and a stable/nightly update channel Every master push publishes its artifacts as a prerelease of 4gray/iptvnator-nightly instead of the rolling test-master draft, with a version of <next patch>-nightly.<commit date>.<run number> applied in every build job. Settings → About gains an Update channel switch; AppUpdateService re-points electron-updater per check (feed repository, allowPrerelease, channel name, allowDowngrade reset) and reads release notes from the repository the requested version belongs to. Channel switches are forward-only: a nightly build stays until a newer stable release exists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(updater): compute the nightly version once and keep re-runs safe Review follow-ups: the nightly version is resolved by a leading job and handed to every build job, and the patch is bumped only when the base tag already exists so the release-cut window stays below the imminent release. A re-run never deletes a published nightly; only a draft left by a failed run is replaced. Typed update-status literals in the remaining specs carry the new channel fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(packaging): expect the nightly-version prerequisite in the build workflow graph Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f724494b9
commit
6f7973a9fb
57 files changed
+2249
-229
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
StreamFormat,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
normalizeAppUpdateChannel,
|
||||
normalizeEpgOffsetMinutes,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeStartupWindowMode,
|
||||
@@ -49,6 +50,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal',
|
||||
updateChannel: 'stable',
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -288,6 +290,9 @@ export const SettingsStore = signalStore(
|
||||
startupWindowMode: normalizeStartupWindowMode(
|
||||
store.startupWindowMode?.()
|
||||
),
|
||||
updateChannel: normalizeAppUpdateChannel(
|
||||
store.updateChannel?.()
|
||||
),
|
||||
showExternalPlaybackBar:
|
||||
store.showExternalPlaybackBar?.() ??
|
||||
DEFAULT_SETTINGS.showExternalPlaybackBar,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/app-update-channel.util';
|
||||
export * from './lib/channel-drm.interface';
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
appUpdateReleasesApiUrl,
|
||||
appUpdateReleasesPageUrl,
|
||||
appUpdateRepository,
|
||||
appVersionChannel,
|
||||
compareAppVersions,
|
||||
normalizeAppUpdateChannel,
|
||||
parseAppVersion,
|
||||
} from './app-update-channel.util';
|
||||
|
||||
describe('app update channel util', () => {
|
||||
it('collapses unknown channels to stable', () => {
|
||||
expect(normalizeAppUpdateChannel('nightly')).toBe('nightly');
|
||||
expect(normalizeAppUpdateChannel('stable')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel('canary')).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(undefined)).toBe('stable');
|
||||
expect(normalizeAppUpdateChannel(null)).toBe('stable');
|
||||
});
|
||||
|
||||
it('maps each channel to its repository and pages', () => {
|
||||
expect(appUpdateRepository('stable')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator',
|
||||
});
|
||||
expect(appUpdateRepository('nightly')).toEqual({
|
||||
owner: '4gray',
|
||||
repo: 'iptvnator-nightly',
|
||||
});
|
||||
// /releases/latest never resolves to a prerelease.
|
||||
expect(appUpdateReleasesPageUrl('stable')).toBe(
|
||||
'https://github.com/4gray/iptvnator/releases/latest'
|
||||
);
|
||||
expect(appUpdateReleasesPageUrl('nightly')).toBe(
|
||||
'https://github.com/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
expect(appUpdateReleasesApiUrl('nightly')).toBe(
|
||||
'https://api.github.com/repos/4gray/iptvnator-nightly/releases'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the channel a build belongs to off its version', () => {
|
||||
expect(appVersionChannel('0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('v0.23.0')).toBe('stable');
|
||||
expect(appVersionChannel('0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('v0.23.1-nightly.20260915.1234')).toBe(
|
||||
'nightly'
|
||||
);
|
||||
expect(appVersionChannel('0.23.1-beta.1')).toBe('stable');
|
||||
expect(appVersionChannel('junk')).toBe('stable');
|
||||
expect(appVersionChannel(undefined)).toBe('stable');
|
||||
});
|
||||
|
||||
it('parses core and prerelease identifiers', () => {
|
||||
expect(parseAppVersion('v0.23.1-nightly.20260915.7+abc')).toEqual({
|
||||
core: [0, 23, 1],
|
||||
prerelease: ['nightly', '20260915', '7'],
|
||||
});
|
||||
expect(parseAppVersion('0.23')).toBeNull();
|
||||
});
|
||||
|
||||
it('orders versions by semver precedence', () => {
|
||||
expect(compareAppVersions('0.24.0', '0.23.0')).toBe(1);
|
||||
expect(compareAppVersions('0.23.0', '0.23.0')).toBe(0);
|
||||
// A nightly sits between the released version and the next patch.
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.0')).toBe(
|
||||
1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.23.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-nightly.20260915.7', '0.24.0')).toBe(
|
||||
-1
|
||||
);
|
||||
// Same day: the run number decides numerically, not lexically.
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260915.1000',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
expect(
|
||||
compareAppVersions(
|
||||
'0.23.1-nightly.20260916.1',
|
||||
'0.23.1-nightly.20260915.999'
|
||||
)
|
||||
).toBe(1);
|
||||
// Fewer identifiers rank lower; numeric identifiers rank below words.
|
||||
expect(compareAppVersions('0.23.1-nightly', '0.23.1-nightly.1')).toBe(
|
||||
-1
|
||||
);
|
||||
expect(compareAppVersions('0.23.1-1', '0.23.1-alpha')).toBe(-1);
|
||||
// Junk never looks like an update.
|
||||
expect(compareAppVersions('junk', '0.23.0')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Desktop update channel. Electron only.
|
||||
*
|
||||
* `stable` follows the tagged releases of `4gray/iptvnator`; `nightly`
|
||||
* follows the prereleases that every master merge publishes to
|
||||
* `4gray/iptvnator-nightly`. The value is persisted with the other settings
|
||||
* in the renderer and mirrored into the main-process config by the
|
||||
* `SETTINGS_UPDATE` handler, because the startup update check runs before
|
||||
* any renderer exists to ask.
|
||||
*
|
||||
* Channel switches are forward-only on purpose: a nightly build stays
|
||||
* installed until the next stable version is newer than it. A downgrade
|
||||
* could land on a release that does not understand the database schema a
|
||||
* nightly migration already applied.
|
||||
*/
|
||||
export type AppUpdateChannel = 'stable' | 'nightly';
|
||||
|
||||
export const APP_UPDATE_CHANNELS: readonly AppUpdateChannel[] = [
|
||||
'stable',
|
||||
'nightly',
|
||||
];
|
||||
|
||||
export const DEFAULT_APP_UPDATE_CHANNEL: AppUpdateChannel = 'stable';
|
||||
|
||||
/**
|
||||
* Prerelease identifier every nightly version carries
|
||||
* (`0.23.1-nightly.20260915.1234`). electron-builder derives the updater
|
||||
* channel file (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`) from
|
||||
* it, and electron-updater matches release tags on the same identifier.
|
||||
*/
|
||||
export const NIGHTLY_PRERELEASE_TAG = 'nightly';
|
||||
|
||||
/** Collapses anything that is not a known channel to `stable`. */
|
||||
export function normalizeAppUpdateChannel(value: unknown): AppUpdateChannel {
|
||||
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
|
||||
? (value as AppUpdateChannel)
|
||||
: DEFAULT_APP_UPDATE_CHANNEL;
|
||||
}
|
||||
|
||||
interface AppUpdateRepository {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
const APP_UPDATE_REPOSITORIES: Record<AppUpdateChannel, AppUpdateRepository> = {
|
||||
stable: { owner: '4gray', repo: 'iptvnator' },
|
||||
nightly: { owner: '4gray', repo: 'iptvnator-nightly' },
|
||||
};
|
||||
|
||||
export function appUpdateRepository(
|
||||
channel: AppUpdateChannel
|
||||
): AppUpdateRepository {
|
||||
return { ...APP_UPDATE_REPOSITORIES[channel] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page a user can open to install by hand. `/releases/latest` never resolves
|
||||
* to a prerelease, and the nightly repository holds nothing else, so that
|
||||
* channel links to the release list instead.
|
||||
*/
|
||||
export function appUpdateReleasesPageUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
const base = `https://github.com/${owner}/${repo}/releases`;
|
||||
|
||||
return channel === 'stable' ? `${base}/latest` : base;
|
||||
}
|
||||
|
||||
export function appUpdateReleasesApiUrl(channel: AppUpdateChannel): string {
|
||||
const { owner, repo } = APP_UPDATE_REPOSITORIES[channel];
|
||||
|
||||
return `https://api.github.com/repos/${owner}/${repo}/releases`;
|
||||
}
|
||||
|
||||
const VERSION_PATTERN =
|
||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
interface ParsedAppVersion {
|
||||
core: [number, number, number];
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
export function parseAppVersion(
|
||||
value: string | null | undefined
|
||||
): ParsedAppVersion | null {
|
||||
const match = (value ?? '').trim().match(VERSION_PATTERN);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
prerelease: match[4] ? match[4].split('.') : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The channel a build belongs to, read from its version string. */
|
||||
export function appVersionChannel(
|
||||
version: string | null | undefined
|
||||
): AppUpdateChannel {
|
||||
return parseAppVersion(version)?.prerelease[0] === NIGHTLY_PRERELEASE_TAG
|
||||
? 'nightly'
|
||||
: 'stable';
|
||||
}
|
||||
|
||||
function compareIdentifiers(left: string, right: string): number {
|
||||
const leftNumeric = /^\d+$/.test(left);
|
||||
const rightNumeric = /^\d+$/.test(right);
|
||||
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return Math.sign(Number(left) - Number(right));
|
||||
}
|
||||
|
||||
// Semver: numeric identifiers always have lower precedence.
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1;
|
||||
}
|
||||
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver precedence for the version strings the updater sees: core
|
||||
* numbers first, then a release outranks its own prereleases, then the
|
||||
* prerelease identifiers left to right. Unparseable input compares as
|
||||
* equal so a junk version can never look like an update.
|
||||
*/
|
||||
export function compareAppVersions(left: string, right: string): number {
|
||||
const parsedLeft = parseAppVersion(left);
|
||||
const parsedRight = parseAppVersion(right);
|
||||
|
||||
if (!parsedLeft || !parsedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = parsedLeft.core[index] - parsedRight.core[index];
|
||||
|
||||
if (difference !== 0) {
|
||||
return Math.sign(difference);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedLeft.prerelease.length === 0) {
|
||||
return parsedRight.prerelease.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
if (parsedRight.prerelease.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const length = Math.max(
|
||||
parsedLeft.prerelease.length,
|
||||
parsedRight.prerelease.length
|
||||
);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftIdentifier = parsedLeft.prerelease[index];
|
||||
const rightIdentifier = parsedRight.prerelease[index];
|
||||
|
||||
if (leftIdentifier === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (rightIdentifier === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const difference = compareIdentifiers(leftIdentifier, rightIdentifier);
|
||||
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import type { SourceProbeContext, SourceHealthResult } from './source-health';
|
||||
import type { XtreamConnectionFailure } from './xtream-connection-test';
|
||||
import type {
|
||||
@@ -263,7 +264,12 @@ export interface ElectronBridgeAppUpdateStatus {
|
||||
progress?: ElectronBridgeAppUpdateProgress;
|
||||
error?: string;
|
||||
supportedSelfUpdate: boolean;
|
||||
/** Release page for the configured channel; opened for manual installs. */
|
||||
manualDownloadUrl: string;
|
||||
/** Channel the updater checks (`Settings.updateChannel`, mirrored). */
|
||||
channel: AppUpdateChannel;
|
||||
/** Channel the running build came from, read off its version string. */
|
||||
installedChannel: AppUpdateChannel;
|
||||
}
|
||||
|
||||
export type ElectronBridgeAppUpdateReleaseNotesDirection = 'previous' | 'next';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import { Language } from './language.enum';
|
||||
import { StreamFormat } from './stream-format.enum';
|
||||
import { Theme } from './theme.enum';
|
||||
@@ -189,6 +190,15 @@ export interface Settings {
|
||||
* value means `'normal'`. F11 leaves or re-enters fullscreen.
|
||||
*/
|
||||
startupWindowMode?: StartupWindowMode;
|
||||
/**
|
||||
* Desktop update channel: `stable` (tagged releases) or `nightly`
|
||||
* (prereleases published from every master merge). Electron only;
|
||||
* a missing value means `stable`. Mirrored into the main-process config
|
||||
* on Save so the startup update check already uses it. Forward-only:
|
||||
* switching back to stable keeps the installed nightly until a newer
|
||||
* stable release exists.
|
||||
*/
|
||||
updateChannel?: AppUpdateChannel;
|
||||
/** Show the desktop footer bar for external playback status */
|
||||
showExternalPlaybackBar?: boolean;
|
||||
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
|
||||
|
||||
Reference in new issue
Block a user