Files
iptvnator/apps/electron-backend/src/app/services/app-update-release-catalog.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

179 lines
5.2 KiB
TypeScript

import {
AppUpdateChannel,
appUpdateReleasesApiUrl,
appUpdateReleasesPageUrl,
appVersionChannel,
} from '@iptvnator/shared/interfaces';
const GITHUB_RELEASES_PER_PAGE = 10;
interface GitHubReleaseResponse {
body?: string | null;
draft?: boolean;
html_url?: string;
name?: string | null;
prerelease?: boolean;
published_at?: string | null;
tag_name?: string;
}
export interface CachedGitHubRelease {
bodyMarkdown: string;
draft: boolean;
htmlUrl: string;
prerelease: boolean;
publishedAt?: string | null;
releaseName?: string | null;
tagName: string;
version: string;
}
export interface ReleaseFetchResponse {
json(): Promise<unknown>;
ok: boolean;
status: number;
statusText: string;
}
export type ReleaseFetcher = (
url: string,
init?: { headers?: Record<string, string> }
) => Promise<ReleaseFetchResponse>;
export function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^v/i, '');
}
function isGitHubRelease(value: unknown): value is GitHubReleaseResponse {
return Boolean(
value &&
typeof value === 'object' &&
'tag_name' in value &&
typeof (value as GitHubReleaseResponse).tag_name === 'string'
);
}
/**
* Lazily paged, newest-first list of one channel's published GitHub
* releases. Drafts are never listed. The stable catalog also drops
* prereleases (a beta tag must not become "the latest version"), while the
* nightly catalog keeps only releases whose version carries the nightly
* identifier, so a stray tag in that repository cannot be offered either.
*/
export class AppUpdateReleaseCatalog {
readonly releases: CachedGitHubRelease[] = [];
loadedReleasePages = 0;
loadedAllReleases = false;
constructor(
readonly channel: AppUpdateChannel,
private readonly fetcher: ReleaseFetcher,
private readonly userAgent: string
) {}
get latest(): CachedGitHubRelease | undefined {
return this.releases[0];
}
async ensureFirstReleaseLoaded(): Promise<void> {
while (!this.loadedAllReleases && this.releases.length === 0) {
await this.ensurePageLoaded(this.loadedReleasePages + 1);
}
}
async ensurePageLoaded(page: number): Promise<void> {
while (!this.loadedAllReleases && this.loadedReleasePages < page) {
await this.loadPage(this.loadedReleasePages + 1);
}
}
/** Index of `version` (or its tag), paging further until it is found. */
async findIndex(version: string | undefined): Promise<number> {
if (!version) {
return -1;
}
const normalizedVersion = normalizeVersion(version);
while (true) {
const index = this.releases.findIndex(
(release) =>
normalizeVersion(release.version) === normalizedVersion ||
normalizeVersion(release.tagName) === normalizedVersion
);
if (index !== -1 || this.loadedAllReleases) {
return index;
}
await this.ensurePageLoaded(this.loadedReleasePages + 1);
}
}
private belongsToChannel(release: GitHubReleaseResponse): boolean {
if (release.draft) {
return false;
}
if (this.channel === 'nightly') {
return appVersionChannel(release.tag_name) === 'nightly';
}
return !release.prerelease;
}
private async loadPage(page: number): Promise<void> {
const url = `${appUpdateReleasesApiUrl(this.channel)}?per_page=${GITHUB_RELEASES_PER_PAGE}&page=${page}`;
const response = await this.fetcher(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': this.userAgent,
},
});
if (!response.ok) {
throw new Error(
`GitHub releases request failed: ${response.status} ${response.statusText}`
);
}
const payload = await response.json();
if (!Array.isArray(payload)) {
throw new Error('GitHub releases response was not an array');
}
const releasesPageUrl = appUpdateReleasesPageUrl(this.channel).replace(
/\/latest$/,
''
);
for (const release of payload) {
if (!isGitHubRelease(release) || !this.belongsToChannel(release)) {
continue;
}
const tagName = release.tag_name;
if (this.releases.some((cached) => cached.tagName === tagName)) {
continue;
}
this.releases.push({
bodyMarkdown: release.body ?? '',
draft: Boolean(release.draft),
htmlUrl:
release.html_url ?? `${releasesPageUrl}/tag/${tagName}`,
prerelease: Boolean(release.prerelease),
publishedAt: release.published_at,
releaseName: release.name ?? tagName,
tagName,
version: normalizeVersion(tagName),
});
}
this.loadedReleasePages = page;
this.loadedAllReleases = payload.length < GITHUB_RELEASES_PER_PAGE;
}
}