diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 05e3abb4d..7a91c9b8d 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -14,13 +14,13 @@ name: Build and Make Electron App # window is visible and bounded; refreshing drafts on skipped runs is not # worth a separate workflow. # -# Master pushes are the nightly channel: every build job rewrites the -# package.json version to -nightly.. -# (tools/release/nightly-version.mjs) so electron-updater treats the build -# as newer than the released version, and the release job publishes the -# artifacts as a prerelease of 4gray/iptvnator-nightly instead of the -# rolling test-master draft. Contract: docs/architecture/release-pipeline.md -# ("Nightly channel"). +# Master pushes are the nightly channel: the nightly-version job computes +# one -nightly.. version for the whole run +# (tools/release/nightly-version.mjs), every build job writes it into +# package.json so electron-updater treats the build as newer than the +# released version, and the release job publishes the artifacts as a +# prerelease of 4gray/iptvnator-nightly instead of the rolling test-master +# draft. Contract: docs/architecture/release-pipeline.md ("Nightly channel"). on: push: branches: @@ -50,6 +50,39 @@ permissions: contents: read jobs: + # One version for the whole run. Computed here rather than in each build + # job because the rule depends on whether the base tag exists on origin: + # a tag pushed while the matrix runs would otherwise give one run two + # different versions. Empty output means "not a nightly build". + nightly-version: + name: Resolve nightly version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.resolve.outputs.version }} + steps: + - name: Checkout code + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' + uses: actions/checkout@v7 + + - name: Resolve nightly version + id: resolve + shell: bash + env: + NIGHTLY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' }} + run: | + set -euo pipefail + + if [ "${NIGHTLY}" != "true" ]; then + echo "version=" >> "${GITHUB_OUTPUT}" + echo "Not a master push; no nightly version." + exit 0 + fi + + VERSION="$(node tools/release/nightly-version.mjs)" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "Nightly version: ${VERSION}" + linux-embedded-mpv-runtime: name: Build pinned Linux Embedded MPV runtime runs-on: ubuntu-22.04 @@ -348,6 +381,7 @@ jobs: build-cross-platform: name: Build on ${{ matrix.os }} ${{ matrix.arch }} + needs: nightly-version runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: @@ -457,11 +491,13 @@ jobs: # Master merges feed the nightly update channel. The version # must be greater than the released one for electron-updater to # offer it, and it must be in package.json before the frontend - # and backend builds and electron-builder read it. Every job of - # this run derives the same value from the commit date and the - # run number, so nothing has to be handed between jobs. - if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == '4gray/iptvnator' - run: node tools/release/nightly-version.mjs --apply + # and backend builds and electron-builder read it. The value + # comes from the nightly-version job so every job of this run + # builds the same version. + if: needs.nightly-version.outputs.version != '' + env: + NIGHTLY_VERSION: ${{ needs.nightly-version.outputs.version }} + run: node tools/release/nightly-version.mjs --apply --version "${NIGHTLY_VERSION}" - name: Build frontend run: pnpm nx build web --skip-nx-cache @@ -1260,7 +1296,9 @@ jobs: build-linux: name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }}) - needs: linux-embedded-mpv-runtime + needs: + - linux-embedded-mpv-runtime + - nightly-version runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: @@ -1297,6 +1335,7 @@ jobs: create-release: name: Create Draft Release needs: + - nightly-version - build-cross-platform - build-linux if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} @@ -1665,10 +1704,14 @@ jobs: shell: bash env: NIGHTLY_RELEASE_TOKEN: ${{ secrets.NIGHTLY_RELEASE_TOKEN }} + VERSION: ${{ needs.nightly-version.outputs.version }} run: | set -euo pipefail - VERSION="$(node tools/release/nightly-version.mjs)" + if [ -z "${VERSION}" ]; then + echo "::error::The nightly-version job produced no version for this master push." + exit 1 + fi { echo "version=${VERSION}" echo "tag=v${VERSION}" @@ -1738,10 +1781,12 @@ jobs: # Created as a draft, assets uploaded, then published in one edit, # so electron-updater never sees a release whose channel file is - # still missing. The release job is serialized per ref but two - # master runs can still finish out of order, so a nightly that is - # older than the newest published one is dropped instead of - # becoming the feed's newest entry. + # still missing. A published release is never deleted here: a + # rerun after a successful publish is a no-op, and only a draft + # left behind by a failed run is replaced. The release job is + # serialized per ref but two master runs can still finish out of + # order, so a nightly that is older than the newest published one + # is dropped instead of becoming the feed's newest entry. - name: Publish nightly release if: steps.nightly-meta.outputs.publish == 'true' shell: bash @@ -1799,9 +1844,16 @@ jobs: artifacts/windows-artifacts/*.blockmap ) - if gh release view "${TAG}" --repo "${NIGHTLY_REPOSITORY}" > /dev/null 2>&1; then - echo "Release ${TAG} already exists (re-run); replacing it." - gh release delete "${TAG}" --repo "${NIGHTLY_REPOSITORY}" --cleanup-tag --yes + EXISTING="$(gh api "repos/${NIGHTLY_REPOSITORY}/releases?per_page=100" --paginate | + jq -c --arg tag "${TAG}" 'map(select(.tag_name == $tag)) | first // empty')" + if [ -n "${EXISTING}" ]; then + if [ "$(jq -r '.draft' <<< "${EXISTING}")" != "true" ]; then + echo "::notice::${TAG} is already published in ${NIGHTLY_REPOSITORY}; nothing to do for this re-run." + exit 0 + fi + DRAFT_ID="$(jq -r '.id' <<< "${EXISTING}")" + echo "Removing the draft ${TAG} (id ${DRAFT_ID}) a failed run left behind." + gh api -X DELETE "repos/${NIGHTLY_REPOSITORY}/releases/${DRAFT_ID}" fi gh release create "${TAG}" "${assets[@]}" \ diff --git a/CLAUDE.md b/CLAUDE.md index 990df319d..7d1012edf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1715,7 +1715,7 @@ The factory pattern ensures a single codebase works in both environments without CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `" ()"`. The semver version itself stays untouched on PR and tag builds — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version. **Nightly Builds And Update Channel**: -Master pushes are the nightly channel. Each build job rewrites the `package.json` version to `-nightly..` (`tools/release/nightly-version.mjs --apply`, deterministic per run), and the `create-release` job publishes the artifacts as a prerelease of `4gray/iptvnator-nightly` (secret `NIGHTLY_RELEASE_TOKEN`; missing token only warns) instead of the rolling `test-master` draft, keeping the newest 20. electron-builder names the updater metadata after the prerelease tag (`nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`), so upload globs and the macOS merge accept both names. `Settings.updateChannel` (Settings → About, Electron only, default `stable`) is mirrored into the main-process config (`APP_UPDATE_CHANNEL`, `app-update-channel.ts`) for the startup check; `AppUpdateService` applies the channel to electron-updater before every check (`app-update-feed.ts`: feed repository, `allowPrerelease`, channel name, and `allowDowngrade` forced back to `false` — assigning a channel silently enables downgrades) and reads release notes from the repository the requested version belongs to (`app-update-release-catalog.ts`). Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a downgrade could hit a database schema a nightly migration already applied. Contract: `docs/architecture/release-pipeline.md` ("Nightly channel"). +Master pushes are the nightly channel. The leading `nightly-version` job computes one `-nightly..` version per run (`tools/release/nightly-version.mjs`; the patch is bumped only when `v` already exists on origin, so the release-cut window stays below the imminent release), every build job writes it into `package.json` (`--apply --version`), and the `create-release` job publishes the artifacts as a prerelease of `4gray/iptvnator-nightly` (secret `NIGHTLY_RELEASE_TOKEN`; missing token only warns) instead of the rolling `test-master` draft, keeping the newest 20. electron-builder names the updater metadata after the prerelease tag (`nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`), so upload globs and the macOS merge accept both names. `Settings.updateChannel` (Settings → About, Electron only, default `stable`) is mirrored into the main-process config (`APP_UPDATE_CHANNEL`, `app-update-channel.ts`) for the startup check; `AppUpdateService` applies the channel to electron-updater before every check (`app-update-feed.ts`: feed repository, `allowPrerelease`, channel name, and `allowDowngrade` forced back to `false` — assigning a channel silently enables downgrades) and reads release notes from the repository the requested version belongs to (`app-update-release-catalog.ts`). Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a downgrade could hit a database schema a nightly migration already applied. Contract: `docs/architecture/release-pipeline.md` ("Nightly channel"). ### Testing Strategy diff --git a/apps/electron-backend/src/app/events/app-update.events.spec.ts b/apps/electron-backend/src/app/events/app-update.events.spec.ts index d0e80b40f..799893e1c 100644 --- a/apps/electron-backend/src/app/events/app-update.events.spec.ts +++ b/apps/electron-backend/src/app/events/app-update.events.spec.ts @@ -14,8 +14,9 @@ jest.mock('electron', () => ({ ipcMain: { handle: jest.fn( (channel: string, handler: (...args: unknown[]) => unknown) => { - mockHandlers.set(channel, handler); - }), + mockHandlers.set(channel, handler); + } + ), }, })); @@ -32,6 +33,8 @@ describe('AppUpdateEvents', () => { 'https://github.com/4gray/iptvnator/releases/latest', status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle, supportedSelfUpdate: true, + channel: 'stable', + installedChannel: 'stable', }; const service = { checkForUpdates: jest.fn().mockResolvedValue({ @@ -59,7 +62,8 @@ describe('AppUpdateEvents', () => { status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded, })), }; - const { default: AppUpdateEvents } = await import('./app-update.events'); + const { default: AppUpdateEvents } = + await import('./app-update.events'); AppUpdateEvents.bootstrapAppUpdateEvents(service); diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index 110f07e51..f567a2573 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -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', () => { diff --git a/apps/web/src/app/services/app-update-install.service.spec.ts b/apps/web/src/app/services/app-update-install.service.spec.ts index dd2639428..658efc39c 100644 --- a/apps/web/src/app/services/app-update-install.service.spec.ts +++ b/apps/web/src/app/services/app-update-install.service.spec.ts @@ -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', }; /** diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index e228da4df..f422d7427 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -302,15 +302,20 @@ frontend and backend builds and before electron-builder reads it - electron-builder derives the updater channel files from the prerelease tag: `nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml`. The artifact upload globs and the macOS metadata merge accept both names. -- Every job derives the same value from the same inputs (commit date + run - number), so nothing is handed between jobs. The release job recomputes it - to name the tag `v`. - The root `package.json` is an Nx `sharedGlobals` input, so the rewritten version reaches the `web` and `electron-backend` bundles (which embed it) instead of a cache hit built from the released version. -- The base is the released version in `package.json`. In the short window - between a version bump commit and its tag, a nightly is numbered above the - upcoming release; the next nightly after the tag corrects that. +- The base is the version in `package.json`. The patch is bumped only when + `v` already exists on origin. A release cut commits the bump before + (or together with) its tag, and while that tag is missing the base is the + UPCOMING release, so the nightly keeps its patch + (`0.23.1` untagged → `0.23.1-nightly..`): still above every + earlier nightly, still below the imminent `0.23.1`, so nightly users are + offered that release instead of skipping it. +- The version is computed once, in the leading `nightly-version` job, and + handed to every build job as `--version` — a tag pushed while the matrix + runs cannot give one run two different versions. The release job reads + the same output to name the tag `v`. **Publication** (steps at the end of the `create-release` job): @@ -327,7 +332,10 @@ frontend and backend builds and before electron-builder reads it 3. The release is created as a draft, assets are uploaded, then it is published in one edit, so electron-updater never sees a release whose channel file is still missing. Missing `nightly-mac.yml`, - `nightly.yml` or `nightly-linux.yml` fails the step instead. + `nightly.yml` or `nightly-linux.yml` fails the step instead. A published + release is never deleted by a re-run: re-running after a successful + publish is a no-op, and only a draft left behind by a failed run is + replaced. 4. The release job is serialized per ref, but two master runs can finish out of order. electron-updater takes the newest feed entry, so a nightly older than the newest published one is dropped rather than published. diff --git a/tools/release/nightly-version.mjs b/tools/release/nightly-version.mjs index 3d79a6db4..86c457200 100644 --- a/tools/release/nightly-version.mjs +++ b/tools/release/nightly-version.mjs @@ -7,7 +7,7 @@ * 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.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. @@ -19,15 +19,27 @@ * - 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. + * A release cut commits the new version to master before (or together + * with) its tag. While `v` 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 the version - * node tools/release/nightly-version.mjs --apply # also write package.json + * 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, and - * `GITHUB_RUN_NUMBER`; `--base`, `--date` and `--run-number` override them. + * 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'; @@ -38,13 +50,21 @@ 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 }) { +export function buildNightlyVersion({ + baseVersion, + date, + runNumber, + baseReleased = true, +}) { const match = STABLE_VERSION_PATTERN.exec(String(baseVersion ?? '').trim()); if (!match) { @@ -66,8 +86,26 @@ export function buildNightlyVersion({ baseVersion, date, runNumber }) { } const [, major, minor, patch] = match; + const nightlyPatch = baseReleased ? Number(patch) + 1 : Number(patch); - return `${major}.${minor}.${Number(patch) + 1}-${NIGHTLY_TAG}.${date}.${run}`; + 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. */ @@ -127,23 +165,78 @@ function readCommitDate() { return commitDate.toISOString().slice(0, 10).replace(/-/g, ''); } +/** Whether `v` 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] [--base X.Y.Z] [--date YYYYMMDD] [--run-number N]' + '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'); - const version = buildNightlyVersion({ - baseVersion: options.base ?? JSON.parse(packageJsonText).version, - date: options.date ?? readCommitDate(), - runNumber: options.runNumber ?? process.env.GITHUB_RUN_NUMBER, - }); + let version; + + try { + version = resolveVersion(options, packageJsonText); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } if (options.apply) { writeFileSync( diff --git a/tools/release/nightly-version.test.mjs b/tools/release/nightly-version.test.mjs index f1ba79ab7..e9abf02c5 100644 --- a/tools/release/nightly-version.test.mjs +++ b/tools/release/nightly-version.test.mjs @@ -3,7 +3,9 @@ import { describe, it } from 'node:test'; import { applyNightlyVersion, buildNightlyVersion, + isNightlyVersion, parseArguments, + parseBooleanFlag, } from './nightly-version.mjs'; describe('buildNightlyVersion', () => { @@ -76,6 +78,30 @@ describe('buildNightlyVersion', () => { 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( () => @@ -138,6 +164,25 @@ describe('applyNightlyVersion', () => { }); }); +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( @@ -150,12 +195,18 @@ describe('parseArguments', () => { '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', } ); });