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
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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 (tag v0.23.0 exists) → 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`).
|
||||
*
|
||||
* A release cut commits the new version to master before (or together
|
||||
* with) its tag. While `v<package.json version>` does not exist yet, that
|
||||
* version is the UPCOMING release, so the patch is not bumped:
|
||||
*
|
||||
* 0.23.1 (no tag v0.23.1 yet) → 0.23.1-nightly.20260915.1240
|
||||
*
|
||||
* That still orders above every earlier `0.23.1-nightly.*` (the run number
|
||||
* grew) and below the imminent `0.23.1`, so nightly users are offered the
|
||||
* release instead of skipping it.
|
||||
*
|
||||
* The workflow computes the version once, in a leading job, and hands it to
|
||||
* every build job with `--version`, so a tag pushed while the matrix runs
|
||||
* cannot give one run two different versions.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/release/nightly-version.mjs # print
|
||||
* node tools/release/nightly-version.mjs --apply --version X # write X
|
||||
*
|
||||
* Inputs default to the repository `package.json`, the HEAD commit date,
|
||||
* `GITHUB_RUN_NUMBER`, and a `git ls-remote` probe for the base tag;
|
||||
* `--base`, `--date`, `--run-number` and `--base-released` 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 NIGHTLY_VERSION_PATTERN = /^\d+\.\d+\.\d+-nightly\.\d{8}\.\d+$/;
|
||||
const VALUE_FLAGS = {
|
||||
'--base': 'base',
|
||||
'--base-released': 'baseReleased',
|
||||
'--date': 'date',
|
||||
'--run-number': 'runNumber',
|
||||
'--version': 'version',
|
||||
};
|
||||
|
||||
export function buildNightlyVersion({
|
||||
baseVersion,
|
||||
date,
|
||||
runNumber,
|
||||
baseReleased = true,
|
||||
}) {
|
||||
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;
|
||||
const nightlyPatch = baseReleased ? Number(patch) + 1 : Number(patch);
|
||||
|
||||
return `${major}.${minor}.${nightlyPatch}-${NIGHTLY_TAG}.${date}.${run}`;
|
||||
}
|
||||
|
||||
/** Accepts only the shape this script produces, so `--version` cannot smuggle junk in. */
|
||||
export function isNightlyVersion(value) {
|
||||
return NIGHTLY_VERSION_PATTERN.test(String(value ?? ''));
|
||||
}
|
||||
|
||||
export function parseBooleanFlag(value, flag) {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`${flag} must be "true" or "false", got "${value}".`);
|
||||
}
|
||||
|
||||
/** 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, '');
|
||||
}
|
||||
|
||||
/** Whether `v<base>` already exists on the origin remote. */
|
||||
function isBaseVersionReleased(baseVersion) {
|
||||
try {
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-remote',
|
||||
'--exit-code',
|
||||
'--tags',
|
||||
'origin',
|
||||
`refs/tags/v${baseVersion}`,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Exit status 2 is git's "no matching refs"; anything else (no
|
||||
// remote, network) must not silently pick a version.
|
||||
if (error && error.status === 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot probe origin for tag v${baseVersion}: ${error?.message ?? error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVersion(options, packageJsonText) {
|
||||
if (options.version !== undefined) {
|
||||
if (!isNightlyVersion(options.version)) {
|
||||
throw new Error(
|
||||
`--version must look like X.Y.Z-nightly.YYYYMMDD.N, got "${options.version}".`
|
||||
);
|
||||
}
|
||||
|
||||
return options.version;
|
||||
}
|
||||
|
||||
const baseVersion = options.base ?? JSON.parse(packageJsonText).version;
|
||||
|
||||
return buildNightlyVersion({
|
||||
baseVersion,
|
||||
date: options.date ?? readCommitDate(),
|
||||
runNumber: options.runNumber ?? process.env.GITHUB_RUN_NUMBER,
|
||||
baseReleased:
|
||||
options.baseReleased === undefined
|
||||
? isBaseVersionReleased(baseVersion)
|
||||
: parseBooleanFlag(options.baseReleased, '--base-released'),
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArguments(argv);
|
||||
|
||||
if (!options) {
|
||||
console.error(
|
||||
'Usage: node tools/release/nightly-version.mjs [--apply] [--version X.Y.Z-nightly.YYYYMMDD.N | --base X.Y.Z --base-released true|false --date YYYYMMDD --run-number N]'
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||
const packageJsonText = readFileSync(packageJsonPath, 'utf8');
|
||||
let version;
|
||||
|
||||
try {
|
||||
version = resolveVersion(options, packageJsonText);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
applyNightlyVersion,
|
||||
buildNightlyVersion,
|
||||
isNightlyVersion,
|
||||
parseArguments,
|
||||
parseBooleanFlag,
|
||||
} 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('keeps the base patch while the base version is not tagged yet', () => {
|
||||
// The release-cut commit bumps package.json to 0.23.1 before the
|
||||
// v0.23.1 tag exists: the nightly must sit BELOW 0.23.1 so the
|
||||
// imminent stable release is still offered.
|
||||
assert.equal(
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.1',
|
||||
date: '20260915',
|
||||
runNumber: 1240,
|
||||
baseReleased: false,
|
||||
}),
|
||||
'0.23.1-nightly.20260915.1240'
|
||||
);
|
||||
assert.equal(
|
||||
buildNightlyVersion({
|
||||
baseVersion: '0.23.1',
|
||||
date: '20260915',
|
||||
runNumber: 1241,
|
||||
baseReleased: true,
|
||||
}),
|
||||
'0.23.2-nightly.20260915.1241'
|
||||
);
|
||||
});
|
||||
|
||||
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('explicit version input', () => {
|
||||
it('accepts only the shape the script itself produces', () => {
|
||||
assert.equal(isNightlyVersion('0.23.1-nightly.20260915.1234'), true);
|
||||
assert.equal(isNightlyVersion('0.23.1'), false);
|
||||
assert.equal(isNightlyVersion('0.23.1-beta.1'), false);
|
||||
assert.equal(isNightlyVersion('0.23.1-nightly.2026915.1'), false);
|
||||
assert.equal(isNightlyVersion(undefined), false);
|
||||
});
|
||||
|
||||
it('parses the base-released flag strictly', () => {
|
||||
assert.equal(parseBooleanFlag('true', '--base-released'), true);
|
||||
assert.equal(parseBooleanFlag('false', '--base-released'), false);
|
||||
assert.throws(
|
||||
() => parseBooleanFlag('yes', '--base-released'),
|
||||
/--base-released must be "true" or "false"/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
it('reads the flags and ignores a bare separator', () => {
|
||||
assert.deepEqual(
|
||||
parseArguments([
|
||||
'--',
|
||||
'--apply',
|
||||
'--base',
|
||||
'0.23.0',
|
||||
'--date',
|
||||
'20260915',
|
||||
'--run-number',
|
||||
'12',
|
||||
'--base-released',
|
||||
'false',
|
||||
'--version',
|
||||
'0.23.0-nightly.20260915.12',
|
||||
]),
|
||||
{
|
||||
apply: true,
|
||||
base: '0.23.0',
|
||||
date: '20260915',
|
||||
runNumber: '12',
|
||||
baseReleased: 'false',
|
||||
version: '0.23.0-nightly.20260915.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);
|
||||
});
|
||||
});
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user