Files
iptvnator/libs/shared/interfaces/src/lib/app-update-channel.util.ts
T
4grayandClaude Fable 5.1 6f7973a9fb 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>
2026-09-15 17:49:22 +02:00

178 lines
5.4 KiB
TypeScript

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