Files
iptvnator/tools/release/nightly-version.mjs
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

256 lines
8.0 KiB
JavaScript

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