From d42c93140027cb8289c284f96f8c0eae4d45b741 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 15:27:23 +0200 Subject: [PATCH] fix(website): pin downloads to the published release --- CHANGELOG.md | 2 +- apps/website/README.md | 15 ++++--- apps/website/astro.config.mjs | 13 ++---- apps/website/released-version.json | 3 ++ apps/website/src/env.d.ts | 8 +--- apps/website/src/lib/downloads.ts | 18 ++++---- tools/testing/website-download-pages.test.mjs | 41 ++++++++++++++++++- 7 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 apps/website/released-version.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b06c117..3e0e17385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ this marker — see `.changes/README.md`. -# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21) +# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-24) ![IPTVnator 0.24.0 — programme guide, fullscreen browsing and stream info](https://raw.githubusercontent.com/4gray/iptvnator/v0.24.0/apps/website/public/blog/v0-24/announce.png) diff --git a/apps/website/README.md b/apps/website/README.md index b85179cda..9ede7d72b 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -74,11 +74,12 @@ resolves it at build time: options whose file is missing are dropped, sizes and the publish date come from the API. `deploy-website.yml` passes `GITHUB_TOKEN` to the build so the call is authenticated. -2. Fallback: the root `package.json` version with the asset naming pattern - from `electron-builder.json`. This is deterministic but cannot prove the - files exist yet (a version bump lands on `master` before the release is - published), so a warning is printed. Set `WEBSITE_SKIP_RELEASE_FETCH=1` to - force it for offline or reproducible builds. +2. Fallback: the published version pinned in `released-version.json` with + the asset naming pattern from `electron-builder.json`. Advance this pin + only after that GitHub release is public and its assets are verified, in + the follow-up commit that publishes the article. It must stay independent + of the root `package.json` development/nightly version. Set + `WEBSITE_SKIP_RELEASE_FETCH=1` to force it for offline or reproducible builds. Both paths produce the same page structure. Adding an artifact means adding a `DownloadOption` (matcher + fallback name) in `downloads.ts`; the pages and the @@ -88,7 +89,9 @@ resolved version. `pnpm nx test website` builds the site and runs `tools/testing/website-download-pages.test.mjs`, which checks titles, canonicals, direct asset links, JSON-LD, cross-links and sitemap entries -without depending on a specific version. +without depending on a specific version. Resolver regression tests use the real +Astro build constants and cover offline builds, rate limits and timeouts, +ensuring each platform keeps links to the pinned published release. Two of the suites drive the built site in a real browser: `tools/testing/website-screenshot-showcase.test.mjs` (the home page channel diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs index c89e7d4cc..57aef4ddb 100644 --- a/apps/website/astro.config.mjs +++ b/apps/website/astro.config.mjs @@ -4,16 +4,9 @@ import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import mdx from '@astrojs/mdx'; -/** - * Repository version for the download pages' offline fallback - * (`src/lib/downloads.ts`). Read from the workspace root through the file - * system and injected as a build-time constant: importing the root - * `package.json` from inside the project trips the Nx module-boundaries rule - * ("external resources cannot be imported using a relative path"), and a - * value fixed at build time is what the fallback needs anyway. - */ +/** Published version for offline downloads; independent of the nightly base. */ const { version } = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), + readFileSync(new URL('./released-version.json', import.meta.url), 'utf8'), ); // Tailwind (v3) is applied via apps/website/postcss.config.mjs — the @@ -26,7 +19,7 @@ export default defineConfig({ integrations: [sitemap(), mdx()], vite: { define: { - __IPTVNATOR_VERSION__: JSON.stringify(version), + __IPTVNATOR_RELEASED_VERSION__: JSON.stringify(version), }, }, }); diff --git a/apps/website/released-version.json b/apps/website/released-version.json new file mode 100644 index 000000000..00f694eaf --- /dev/null +++ b/apps/website/released-version.json @@ -0,0 +1,3 @@ +{ + "version": "0.24.0" +} diff --git a/apps/website/src/env.d.ts b/apps/website/src/env.d.ts index ee4d25b10..ddb61c3c8 100644 --- a/apps/website/src/env.d.ts +++ b/apps/website/src/env.d.ts @@ -1,8 +1,4 @@ /// -/** - * Repository version from the workspace root `package.json`, injected at - * build time by `astro.config.mjs` through Vite `define`. Used by the download - * pages' offline fallback (`src/lib/downloads.ts`). - */ -declare const __IPTVNATOR_VERSION__: string; +/** Published release from released-version.json, injected by astro.config.mjs. */ +declare const __IPTVNATOR_RELEASED_VERSION__: string; diff --git a/apps/website/src/lib/downloads.ts b/apps/website/src/lib/downloads.ts index 09997587b..e71f2f6cf 100644 --- a/apps/website/src/lib/downloads.ts +++ b/apps/website/src/lib/downloads.ts @@ -7,13 +7,13 @@ import type { DownloadPlatform } from './platforms'; * *published* release so that every direct link points at an asset that * exists, and so file sizes and the publish date can be shown. When the API * is unreachable (offline build, rate limit, `WEBSITE_SKIP_RELEASE_FETCH=1`) - * the pages fall back to the repository version from the root `package.json` - * — injected as `__IPTVNATOR_VERSION__` by `astro.config.mjs`, since a - * relative import of the root manifest violates the Nx module boundaries — - * and the known asset naming pattern from `electron-builder.json`. + * the pages fall back to the published version in `released-version.json`, + * injected as `__IPTVNATOR_RELEASED_VERSION__` by `astro.config.mjs`, and the + * known asset naming pattern from `electron-builder.json`. The development + * version in the root package manifest must never select download assets. */ -const FALLBACK_VERSION = __IPTVNATOR_VERSION__; +const FALLBACK_VERSION = __IPTVNATOR_RELEASED_VERSION__; export const GITHUB_REPO = '4gray/iptvnator'; export const REPO_URL = `https://github.com/${GITHUB_REPO}`; @@ -38,8 +38,8 @@ export interface ResolvedRelease { url: string; publishedAt: Date | null; assets: ReleaseAsset[]; - /** Where the data came from; the fallback cannot promise that the files exist yet. */ - source: 'github-api' | 'package-json'; + /** Whether the API or the pinned published version supplied the release. */ + source: 'github-api' | 'published-version'; } export interface DownloadOption { @@ -321,10 +321,10 @@ function fallbackRelease(): ResolvedRelease { url: `${RELEASES_URL}/tag/${tag}`, publishedAt: null, assets, - source: 'package-json', + source: 'published-version', }; } function warnFallback(reason: string): void { - console.warn(`[website] Latest release lookup failed (${reason}); using package.json version ${FALLBACK_VERSION}.`); + console.warn(`[website] Latest release lookup failed (${reason}); using pinned published version ${FALLBACK_VERSION}.`); } diff --git a/tools/testing/website-download-pages.test.mjs b/tools/testing/website-download-pages.test.mjs index 448623431..a91c90214 100644 --- a/tools/testing/website-download-pages.test.mjs +++ b/tools/testing/website-download-pages.test.mjs @@ -1,15 +1,19 @@ import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import ts from 'typescript'; +import astroConfig from '../../apps/website/astro.config.mjs'; /** * Structural checks for the per-OS download pages in the built website. * The build resolves the latest release from the GitHub API and falls back to - * package.json, so assertions accept any semver version but insist on direct + * the pinned published version, so assertions accept any semver but insist on direct * asset links, canonical URLs, structured data and internal linking. */ const distRoot = new URL('../../dist/apps/website/', import.meta.url); +const { version: publishedVersion } = JSON.parse(await readFile(new URL('../../apps/website/released-version.json', import.meta.url), 'utf8')); const SITE = 'https://4gray.github.io/iptvnator'; const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8'); @@ -152,3 +156,38 @@ test('sitemap lists the download pages', async () => { assert.match(sitemap, new RegExp(`${SITE}/${path}`)); } }); + +// Use the real Astro defines so a development-version bump cannot silently +// change the offline download URLs. The API is the only mocked boundary. +for (const failure of ['offline', 'rate-limit', 'timeout']) { + test(`release lookup ${failure}: downloads stay on the published release`, async () => { + const source = await readFile(new URL('../../apps/website/src/lib/downloads.ts', import.meta.url), 'utf8'); + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; + const exports = {}; + let fetchCalls = 0; + const warnings = []; + vm.runInNewContext(code, { + exports, + ...Object.fromEntries(Object.entries(astroConfig.vite.define).map(([name, value]) => [name, JSON.parse(value)])), + process: { env: { WEBSITE_SKIP_RELEASE_FETCH: failure === 'offline' ? '1' : '0' } }, + AbortSignal, + console: { warn(message) { warnings.push(message); } }, + fetch: async () => { + fetchCalls++; + if (failure === 'timeout') throw new Error('The operation timed out'); + return { ok: false, status: 403 }; + }, + }); + const release = await exports.getLatestRelease(); + assert.equal(release.version, publishedVersion, 'The published release is independent of the upcoming development version'); + assert.equal(fetchCalls, failure === 'offline' ? 0 : 1); + assert.equal(warnings.length, failure === 'offline' ? 0 : 1); + for (const platform of ['windows', 'macos', 'linux']) { + const downloads = exports.resolveDownloads(release, platform); + assert.ok(downloads.length > 0); + for (const download of downloads) { + assert.ok(download.url.includes(`/releases/download/v${publishedVersion}/iptvnator-${publishedVersion}-`), download.url); + } + } + }); +}