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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-15 10:16:39 +02:00
1 parent 7f724494b9
commit c9272f5635
53 files changed
+2024 -223

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
/**
* Nightly build version for master merges.
*
* Every push to master publishes a prerelease to `4gray/iptvnator-nightly`
* that the desktop updater's nightly channel can install. electron-updater
* only offers a version that is semver-greater than the running one, and the
* released version in `package.json` is what stable users run, so a nightly
* bumps the patch and adds a prerelease tag:
*
* 0.23.0 → 0.23.1-nightly.20260915.1234
*
* - `0.23.1-nightly.*` is greater than the stable `0.23.0`, so a stable user
* who switches to nightly is offered it.
* - It is smaller than both `0.23.1` and `0.24.0`, so the next stable
* release is offered to nightly users on either channel.
* - The date is the commit's UTC date and the last identifier is the
* workflow run number, which only ever grows, so nightlies order
* correctly across days and within one day.
* - electron-builder derives the updater channel file names from the
* prerelease tag (`nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`).
*
* Every build job of one workflow run computes the same value from the same
* inputs, so no job needs to hand the version to another.
*
* Usage:
* node tools/release/nightly-version.mjs # print the version
* node tools/release/nightly-version.mjs --apply # also write package.json
*
* Inputs default to the repository `package.json`, the HEAD commit date, and
* `GITHUB_RUN_NUMBER`; `--base`, `--date` and `--run-number` override them.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export const NIGHTLY_TAG = 'nightly';
const STABLE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
const DATE_PATTERN = /^\d{8}$/;
const PACKAGE_VERSION_LINE = /^(\s*"version":\s*")([^"]+)(")/m;
const VALUE_FLAGS = {
'--base': 'base',
'--date': 'date',
'--run-number': 'runNumber',
};
export function buildNightlyVersion({ baseVersion, date, runNumber }) {
const match = STABLE_VERSION_PATTERN.exec(String(baseVersion ?? '').trim());
if (!match) {
throw new Error(
`Base version must be a released X.Y.Z version, got "${baseVersion}".`
);
}
if (!DATE_PATTERN.test(String(date ?? ''))) {
throw new Error(`Date must be YYYYMMDD, got "${date}".`);
}
const run = Number(runNumber);
if (!Number.isInteger(run) || run <= 0) {
throw new Error(
`Run number must be a positive integer, got "${runNumber}".`
);
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${Number(patch) + 1}-${NIGHTLY_TAG}.${date}.${run}`;
}
/** Replaces the `version` line in `package.json` text, formatting intact. */
export function applyNightlyVersion(packageJsonText, version) {
if (!PACKAGE_VERSION_LINE.test(packageJsonText)) {
throw new Error('package.json has no "version" line to replace.');
}
return packageJsonText.replace(PACKAGE_VERSION_LINE, `$1${version}$3`);
}
export function parseArguments(argv) {
const options = { apply: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') {
continue;
}
if (argument === '--apply') {
options.apply = true;
continue;
}
const optionName = VALUE_FLAGS[argument];
if (optionName) {
const value = argv[index + 1];
if (value === undefined || value.startsWith('--')) {
return null;
}
options[optionName] = value;
index += 1;
continue;
}
return null;
}
return options;
}
function readCommitDate() {
const isoDate = execFileSync('git', ['show', '-s', '--format=%cI', 'HEAD'], {
encoding: 'utf8',
}).trim();
const commitDate = new Date(isoDate);
if (Number.isNaN(commitDate.getTime())) {
throw new Error(`Cannot parse HEAD commit date "${isoDate}".`);
}
return commitDate.toISOString().slice(0, 10).replace(/-/g, '');
}
function main(argv) {
const options = parseArguments(argv);
if (!options) {
console.error(
'Usage: node tools/release/nightly-version.mjs [--apply] [--base X.Y.Z] [--date YYYYMMDD] [--run-number N]'
);
return 2;
}
const packageJsonPath = new URL('../../package.json', import.meta.url);
const packageJsonText = readFileSync(packageJsonPath, 'utf8');
const version = buildNightlyVersion({
baseVersion: options.base ?? JSON.parse(packageJsonText).version,
date: options.date ?? readCommitDate(),
runNumber: options.runNumber ?? process.env.GITHUB_RUN_NUMBER,
});
if (options.apply) {
writeFileSync(
packageJsonPath,
applyNightlyVersion(packageJsonText, version)
);
console.error(`Applied nightly version ${version} to package.json.`);
}
console.log(version);
return 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exitCode = main(process.argv.slice(2));
}
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
applyNightlyVersion,
buildNightlyVersion,
parseArguments,
} from './nightly-version.mjs';
describe('buildNightlyVersion', () => {
it('bumps the patch and appends the nightly identifiers', () => {
assert.equal(
buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260915',
runNumber: '1234',
}),
'0.23.1-nightly.20260915.1234'
);
});
it('orders newer nightlies above older ones and below the next stable', () => {
const semverOrder = (left, right) => {
// Mirror semver precedence closely enough for these shapes:
// numeric identifiers compare numerically, a release outranks
// its prereleases.
const parse = (value) => {
const [core, prerelease = ''] = value.split('-');
return {
core: core.split('.').map(Number),
prerelease: prerelease ? prerelease.split('.') : [],
};
};
const a = parse(left);
const b = parse(right);
for (let index = 0; index < 3; index += 1) {
if (a.core[index] !== b.core[index]) {
return a.core[index] - b.core[index];
}
}
if (a.prerelease.length === 0 || b.prerelease.length === 0) {
return b.prerelease.length - a.prerelease.length;
}
for (let index = 0; index < a.prerelease.length; index += 1) {
const x = a.prerelease[index];
const y = b.prerelease[index];
if (x === y) continue;
return /^\d+$/.test(x) && /^\d+$/.test(y)
? Number(x) - Number(y)
: x < y
? -1
: 1;
}
return 0;
};
const older = buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260915',
runNumber: 1234,
});
const sameDayLater = buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260915',
runNumber: 1240,
});
const nextDay = buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260916',
runNumber: 1241,
});
assert.ok(semverOrder(older, '0.23.0') > 0);
assert.ok(semverOrder(sameDayLater, older) > 0);
assert.ok(semverOrder(nextDay, sameDayLater) > 0);
assert.ok(semverOrder(nextDay, '0.23.1') < 0);
assert.ok(semverOrder(nextDay, '0.24.0') < 0);
});
it('rejects a base version that is already a prerelease', () => {
assert.throws(
() =>
buildNightlyVersion({
baseVersion: '0.23.1-nightly.20260915.1',
date: '20260915',
runNumber: 2,
}),
/released X\.Y\.Z version/
);
});
it('rejects malformed dates and run numbers', () => {
assert.throws(
() =>
buildNightlyVersion({
baseVersion: '0.23.0',
date: '2026-09-15',
runNumber: 1,
}),
/YYYYMMDD/
);
assert.throws(
() =>
buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260915',
runNumber: undefined,
}),
/positive integer/
);
assert.throws(
() =>
buildNightlyVersion({
baseVersion: '0.23.0',
date: '20260915',
runNumber: '0',
}),
/positive integer/
);
});
});
describe('applyNightlyVersion', () => {
it('replaces only the version line and keeps the file formatting', () => {
const source =
'{\n "name": "iptvnator",\n "version": "0.23.0",\n "engines": {\n "node": "22"\n }\n}\n';
assert.equal(
applyNightlyVersion(source, '0.23.1-nightly.20260915.7'),
'{\n "name": "iptvnator",\n "version": "0.23.1-nightly.20260915.7",\n "engines": {\n "node": "22"\n }\n}\n'
);
});
it('fails when the version line is missing', () => {
assert.throws(
() => applyNightlyVersion('{ "name": "x" }', '1.0.1-nightly.1.1'),
/no "version" line/
);
});
});
describe('parseArguments', () => {
it('reads the flags and ignores a bare separator', () => {
assert.deepEqual(
parseArguments([
'--',
'--apply',
'--base',
'0.23.0',
'--date',
'20260915',
'--run-number',
'12',
]),
{
apply: true,
base: '0.23.0',
date: '20260915',
runNumber: '12',
}
);
});
it('reports usage for unknown flags and missing values', () => {
assert.equal(parseArguments(['--unknown']), null);
assert.equal(parseArguments(['--base']), null);
assert.equal(parseArguments(['--base', '--apply']), null);
});
});
+4 -2
View File
@@ -18,6 +18,7 @@
"{workspaceRoot}/tools/release/extract-changelog-section.mjs",
"{workspaceRoot}/tools/release/check-release-note-gate.mjs",
"{workspaceRoot}/tools/release/build-release-notes.mjs",
"{workspaceRoot}/tools/release/nightly-version.mjs",
"{workspaceRoot}/tools/release/screenshot-guards.mjs",
"{workspaceRoot}/tools/release/screenshots.manifest.json",
"{workspaceRoot}/.github/workflows/build-and-make.yaml",
@@ -27,10 +28,11 @@
"{workspaceRoot}/tools/release/release-announcements.test.mjs",
"{workspaceRoot}/tools/release/verify-draft-release.test.mjs",
"{workspaceRoot}/tools/release/highlight-cards.test.mjs",
"{workspaceRoot}/tools/release/screenshot-guards.test.mjs"
"{workspaceRoot}/tools/release/screenshot-guards.test.mjs",
"{workspaceRoot}/tools/release/nightly-version.test.mjs"
],
"options": {
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs",
"command": "node --test tools/release/release-notes.test.mjs tools/release/release-note-gate.test.mjs tools/release/build-release-notes.test.mjs tools/release/release-announcements.test.mjs tools/release/verify-draft-release.test.mjs tools/release/highlight-cards.test.mjs tools/release/screenshot-guards.test.mjs tools/release/nightly-version.test.mjs",
"cwd": "{workspaceRoot}"
}
},