feat(whats-new): remote release notes with dev workspace mode (#1058)

* feat(whats-new): remote release notes with dev workspace mode

Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache
with embedded fallbacks. Dev and -dev builds read the full file from the
repo for debugging; RC/stable download the release asset on first use.

* fix(whats-new): render ## headings and add changelog tab

Parse h2 sections in release-notes markdown; load changelog alongside
highlights and let users switch views on the What's New page.

* fix(whats-new): prefetch on startup and fix CI typecheck prebuild

Prefetch whats-new asset when the shell loads on RC/stable builds.
Run prebuild:release-notes before tsc and coverage jobs so the
gitignored generated bundle exists in CI.

* docs: CHANGELOG and credits for What's New remote notes (PR #1058)

* fix(whats-new): always slice embedded release notes to current line

Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live
markdown from the repo. Ignore all of src/generated/ in git.

* fix(whats-new): fetch release asset via Rust to bypass CORS

Route whats-new.md download through fetch_url_bytes; rename the
technical tab label; add fetch unit tests (PR #1058 review).
This commit is contained in:
cucadmuh
2026-06-10 23:35:23 +03:00
committed by GitHub
parent fb5a257735
commit c7d71ea57c
38 changed files with 956 additions and 186 deletions
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
*
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
function versionCore(version) {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainTriple(header) {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
function splitBlocks(raw) {
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
}
function headerVersion(block) {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
function parseBlock(block) {
const lines = block.split('\n');
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
if (!m) return null;
return {
headerVersion: m[1],
date: (m[2] ?? '').trim(),
body: lines.slice(1).join('\n').trim(),
};
}
export function findReleaseSection(raw, appVersion) {
const blocks = splitBlocks(raw);
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = versionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b) => {
const hv = headerVersion(b);
return hv !== null && versionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b) => {
const hv = headerVersion(b);
return hv !== null && isPlainTriple(hv);
});
return parseBlock(plain ?? candidates[0]);
}
function main() {
const args = process.argv.slice(2);
const allowEmpty = args.includes('--allow-empty');
const positional = args.filter((a) => a !== '--allow-empty');
const [file, version] = positional;
if (!file || !version) {
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
process.exit(2);
}
const raw = readFileSync(file, 'utf8');
const entry = findReleaseSection(raw, version);
const body = entry?.body?.trim() ?? '';
if (!body) {
if (allowEmpty) process.exit(0);
console.error(`No release section found in ${file} for version ${version}`);
process.exit(1);
}
process.stdout.write(`${body}\n`);
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findReleaseSection } from './extract-release-section.mjs';
const FIXTURE = `
## [1.48.0] - 2026-06-10
## Highlights
- One
## [1.47.0]
- Old
`;
describe('findReleaseSection', () => {
it('matches base line for -rc versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
assert.equal(entry.headerVersion, '1.48.0');
assert.match(entry.body, /Highlights/);
});
it('matches base line for -dev versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
assert.equal(entry.headerVersion, '1.48.0');
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Build src/generated/releaseNotesBundle.ts for production bundles.
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findReleaseSection } from './extract-release-section.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const whatsNewPath = join(root, 'WHATS_NEW.md');
const changelogPath = join(root, 'CHANGELOG.md');
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
const changelogFull = readFileSync(changelogPath, 'utf8');
function sliceForVersion(full, fileLabel) {
const entry = findReleaseSection(full, version);
if (!entry?.body) {
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
return '';
}
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
}
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
const outDir = join(root, 'src/generated');
mkdirSync(outDir, { recursive: true });
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
`;
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);