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)

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);
+ }
+ }
+ });
+}