mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -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
@@ -14,6 +14,8 @@ const availableStatus: ElectronBridgeAppUpdateStatus = {
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
};
|
||||
|
||||
describe('AppUpdateNotificationPanelComponent', () => {
|
||||
|
||||
@@ -12,6 +12,9 @@ const BASE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
currentVersion: '0.23.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,65 @@
|
||||
></progress>
|
||||
}
|
||||
</div>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
data-test-id="app-update-channel"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
data-test-id="select-update-channel"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<small class="app-update-channel__note">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
@if (form()!.get('updateChannel')?.value === 'nightly') {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="app-update-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SETTINGS_UPDATE_CHANNEL_OPTIONS } from './settings-options';
|
||||
|
||||
function getButton(fixture: ComponentFixture<SettingsAboutSectionComponent>, id: string) {
|
||||
return fixture.nativeElement.querySelector(
|
||||
@@ -187,3 +189,86 @@ describe('SettingsAboutSectionComponent version display', () => {
|
||||
expect(getCommitMarker()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsAboutSectionComponent update channel', () => {
|
||||
let fixture: ComponentFixture<SettingsAboutSectionComponent>;
|
||||
|
||||
const query = (id: string) =>
|
||||
fixture.nativeElement.querySelector(
|
||||
`[data-test-id="${id}"]`
|
||||
) as HTMLElement | null;
|
||||
|
||||
const configure = (
|
||||
status: Partial<ElectronBridgeAppUpdateStatus>,
|
||||
form: FormGroup | null
|
||||
) => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.componentRef.setInput(
|
||||
'updateChannelOptions',
|
||||
SETTINGS_UPDATE_CHANNEL_OPTIONS
|
||||
);
|
||||
fixture.componentRef.setInput('appUpdateStatus', {
|
||||
currentVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
...status,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsAboutSectionComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
|
||||
});
|
||||
|
||||
it('binds the channel select to the shared settings form', () => {
|
||||
const form = new FormGroup({
|
||||
updateChannel: new FormControl('stable'),
|
||||
});
|
||||
configure({}, form);
|
||||
|
||||
expect(query('select-update-channel')).not.toBeNull();
|
||||
expect(query('app-update-channel-nightly-warning')).toBeNull();
|
||||
|
||||
form.controls.updateChannel.setValue('nightly');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('app-update-channel-nightly-warning')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders no channel control without a form to bind to', () => {
|
||||
configure({}, null);
|
||||
|
||||
expect(query('select-update-channel')).toBeNull();
|
||||
});
|
||||
|
||||
it('explains that a nightly build on the stable channel waits for the next release', () => {
|
||||
const form = new FormGroup({
|
||||
updateChannel: new FormControl('stable'),
|
||||
});
|
||||
configure(
|
||||
{
|
||||
currentVersion: '0.23.1-nightly.20260915.7',
|
||||
installedChannel: 'nightly',
|
||||
},
|
||||
form
|
||||
);
|
||||
|
||||
expect(query('app-update-nightly-on-stable-hint')).not.toBeNull();
|
||||
|
||||
configure({ channel: 'nightly', installedChannel: 'nightly' }, form);
|
||||
|
||||
expect(query('app-update-nightly-on-stable-hint')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,28 @@ import {
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { UpdateChannelOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatButtonModule, MatIconModule, TranslateModule],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
@@ -24,6 +35,9 @@ import {
|
||||
styles: [
|
||||
':host { display: contents; }',
|
||||
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
|
||||
'.app-update-channel { margin-top: 12px; }',
|
||||
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
|
||||
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
|
||||
],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
@@ -34,6 +48,13 @@ export class SettingsAboutSectionComponent {
|
||||
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(
|
||||
null
|
||||
);
|
||||
/**
|
||||
* The shared settings form; the channel select lives in it so a change
|
||||
* goes through the same Save and unsaved-changes flow as every other
|
||||
* setting. Absent in hosts that only render the version block.
|
||||
*/
|
||||
readonly form = input<FormGroup | null>(null);
|
||||
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
|
||||
|
||||
readonly buildCommitShort = computed(() => {
|
||||
const commit = this.buildCommit()?.trim();
|
||||
@@ -47,6 +68,23 @@ export class SettingsAboutSectionComponent {
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
|
||||
readonly canSelectUpdateChannel = computed(
|
||||
() =>
|
||||
this.isDesktop() &&
|
||||
this.form() !== null &&
|
||||
this.updateChannelOptions().length > 0
|
||||
);
|
||||
|
||||
/** A nightly build on the stable channel waits for the next stable release. */
|
||||
readonly isNightlyBuildOnStableChannel = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.installedChannel === 'nightly' &&
|
||||
status.channel === 'stable'
|
||||
);
|
||||
});
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
|
||||
@@ -82,6 +82,38 @@ describe('settings form utils — startup window mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — update channel', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
it('defaults the form control to the stable channel', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
|
||||
expect(form.getRawValue().updateChannel).toBe('stable');
|
||||
});
|
||||
|
||||
it('carries the chosen channel into the settings object', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ updateChannel: 'nightly' });
|
||||
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('nightly');
|
||||
});
|
||||
|
||||
it('collapses a missing or unknown channel to stable', () => {
|
||||
const form = createSettingsForm(formBuilder, true);
|
||||
form.patchValue({ updateChannel: null as unknown as 'stable' });
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('stable');
|
||||
|
||||
form.patchValue({ updateChannel: 'canary' as unknown as 'stable' });
|
||||
expect(
|
||||
createSettingsFromFormValue(form, {} as Settings).updateChannel
|
||||
).toBe('stable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings form utils — EPG display offset', () => {
|
||||
const formBuilder = new FormBuilder();
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
CoverSize,
|
||||
DEFAULT_APP_UPDATE_CHANNEL,
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
DEFAULT_TMDB_SETTINGS,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
normalizeAppUpdateChannel,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
normalizeExternalPlayerArguments,
|
||||
@@ -89,6 +92,7 @@ export function createSettingsForm(
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal' as StartupWindowMode,
|
||||
updateChannel: DEFAULT_APP_UPDATE_CHANNEL as AppUpdateChannel,
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -182,6 +186,7 @@ export function createSettingsFromFormValue(
|
||||
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
|
||||
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
|
||||
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
|
||||
updateChannel: normalizeAppUpdateChannel(value.updateChannel),
|
||||
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
|
||||
stripCountryPrefix: value.stripCountryPrefix ?? false,
|
||||
theme: value.theme ?? Theme.SystemTheme,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
StartupBehaviorOption,
|
||||
StartupWindowModeOption,
|
||||
ThemeOption,
|
||||
UpdateChannelOption,
|
||||
} from './settings.models';
|
||||
|
||||
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
|
||||
@@ -90,6 +91,17 @@ export const SETTINGS_STARTUP_WINDOW_MODE_OPTIONS: StartupWindowModeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_UPDATE_CHANNEL_OPTIONS: UpdateChannelOption[] = [
|
||||
{
|
||||
value: 'stable',
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_STABLE',
|
||||
},
|
||||
{
|
||||
value: 'nightly',
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
@case ('about') {
|
||||
<app-settings-about-section
|
||||
[isDesktop]="isDesktop"
|
||||
[form]="settingsForm"
|
||||
[updateChannelOptions]="updateChannelOptions"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
|
||||
SETTINGS_UPDATE_CHANNEL_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
@@ -169,6 +170,7 @@ export class SettingsComponent
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
|
||||
readonly updateChannelOptions = SETTINGS_UPDATE_CHANNEL_OPTIONS;
|
||||
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
@@ -30,6 +31,11 @@ export interface StartupWindowModeOption {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface UpdateChannelOption {
|
||||
value: AppUpdateChannel;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
|
||||
@@ -97,6 +97,7 @@ export const DEFAULT_SETTINGS = {
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
startupWindowMode: 'normal',
|
||||
updateChannel: 'stable',
|
||||
showExternalPlaybackBar: true,
|
||||
stripCountryPrefix: false,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -128,6 +129,8 @@ export const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
channel: 'stable',
|
||||
installedChannel: 'stable',
|
||||
};
|
||||
|
||||
export class MatSnackBarStub {
|
||||
|
||||
Reference in new issue
Block a user