fix(website): pin downloads to the published release

This commit is contained in:
4gray committed 2026-09-26 15:27:23 +02:00
1 parent 112672b970
commit d42c931400
7 files changed
+67 -33

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ this marker — see `.changes/README.md`.
<!-- next-release -->
# [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)
+9 -6
View File
@@ -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
+3 -10
View File
@@ -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),
},
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"version": "0.24.0"
}
+2 -6
View File
@@ -1,8 +1,4 @@
/// <reference types="astro/client" />
/**
* 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;
+9 -9
View File
@@ -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}.`);
}
+40 -1
View File
@@ -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(`<loc>${SITE}/${path}</loc>`));
}
});
// 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);
}
}
});
}