fix(website): simplify downloads and scope product releases (#390)
## Problem and outcome Website visitors can download the usual Windows x64, macOS Apple silicon and Linux x64 App packages directly from clearly labeled platform cards. Each card includes a verified Gitee alternative, with shared setup guidance and a More downloads link for the full release list. The homepage reuses the application's 20 bilingual greetings, and the sample game no longer displays an invented title. Website builds resolve links from the latest published GitHub release and validate matching mirror assets. Website deployment follows main CI so newly published packages reach the next site build. Version selection and release notes now share a product-change filter: standalone website, documentation and maintenance changes do not create App/Server releases or affect the next product version. ## Verification - `npm run check`: 57 test files / 759 tests, TypeScript and product production build passed. - Website build passed with real GitHub/Gitee v1.0.0 metadata and with the explicit local fallback. - English and Chinese browser checks at 390px and 1440px passed: platform links, More downloads, greeting selection and language switching. - All 23 website film check groups passed; the final shared artwork change rebuilt successfully. - Documentation, repository hygiene and diff checks passed. Real Git-history tests cover product/website mixtures and moves across the release boundary; the accepted phase plans `publish: false`. ## Impact GitHub remains the version authority; Gitee is a download mirror. Unavailable mirror metadata falls back to its release page. Product runtime behavior and package compatibility are unchanged. Physical macOS/Linux capture and broader device/network limits remain documented.
This commit is contained in:
24 files changed
+429
-157
No files matched your search
@@ -19,10 +19,51 @@ await cp(source, output, {
|
||||
!path.endsWith("README.md"),
|
||||
});
|
||||
await cp(new URL("../LICENSE", import.meta.url), new URL("LICENSE", output));
|
||||
const [{ WELCOME_LINES }, { catalogs }, { Glyph }] = await Promise.all([
|
||||
"../src/client/components/living/WelcomeLine.tsx",
|
||||
"../src/client/locales/index.ts",
|
||||
"../src/client/ui/icons.tsx",
|
||||
].map(path => tsImport(path, {
|
||||
parentURL: import.meta.url,
|
||||
tsconfig: fileURLToPath(new URL("../src/tsconfig.json", import.meta.url)),
|
||||
})));
|
||||
// Reuse the product's paired lines as inert HTML, without a runtime catalog.
|
||||
const welcomeLines = WELCOME_LINES.map(({ key }) => renderToStaticMarkup(
|
||||
createElement("span", { "data-welcome-key": key },
|
||||
createElement("span", { lang: "en" }, `“${catalogs.en[key]}”`),
|
||||
createElement("span", { lang: "zh-CN" }, `“${catalogs.zh[key]}”`),
|
||||
),
|
||||
));
|
||||
let homepage = (await readFile(new URL("index.html", source), "utf8"))
|
||||
.replace(/<p id="welcome-line">.*?<\/p>/, `<p id="welcome-line">${welcomeLines[0]}</p>`);
|
||||
if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
|
||||
const { websiteDownloads } = await tsImport("../site/downloads.ts", { parentURL: import.meta.url });
|
||||
const release = websiteDownloads(JSON.parse(await readFile(process.env.PIIK_WEBSITE_RELEASE_DATA, "utf8")));
|
||||
homepage = homepage.replace(/<p class="release-note" id="download-status">[\s\S]*?<\/p>/,
|
||||
`<p class="release-note" id="download-status"><span class="release-label">${release.version}</span>` +
|
||||
`<span lang="en">Download for your system, then extract the archive.</span>` +
|
||||
`<span lang="zh-CN">选择对应系统,下载后解压。</span>` +
|
||||
`<a href="${release.notes}"><span lang="en">Release notes</span><span lang="zh-CN">版本说明</span></a></p>`);
|
||||
homepage = homepage.replace(/<a\b([^>]*data-download="([^"]+)"[^>]*data-provider="([^"]+)"[^>]*)>([\s\S]*?)<\/a\s*>/g,
|
||||
(original, attributes, target, provider, content) => {
|
||||
const item = release.packages.find(item => item.target === target);
|
||||
const href = provider === 'github' ? item?.url : item?.mirrorURL;
|
||||
if (!href) return original;
|
||||
const label = provider === 'github'
|
||||
? { en: 'Download from GitHub', zh: 'GitHub 下载' }
|
||||
: { en: 'Download from Gitee', zh: 'Gitee 备用下载' };
|
||||
return `<a${attributes.replace(/href="[^"]*"/, `href="${href}"`)}>` + content
|
||||
.replace(/<span lang="en">[\s\S]*?<\/span\s*>/, `<span lang="en">${label.en}</span>`)
|
||||
.replace(/<span lang="zh-CN">[\s\S]*?<\/span\s*>/, `<span lang="zh-CN">${label.zh}</span>`) + '</a>';
|
||||
});
|
||||
}
|
||||
await writeFile(new URL("index.html", output), homepage.replace(
|
||||
'</body>', `<template id="welcome-lines">${welcomeLines.join('')}</template></body>`,
|
||||
));
|
||||
// The opening shot uses the current homepage, with only its film clock adapter
|
||||
// substituted for the ordinary page script. Keep its layout and copy in one place.
|
||||
await writeFile(new URL("film/ui/website.html", output),
|
||||
(await readFile(new URL("index.html", source), "utf8"))
|
||||
homepage
|
||||
.replace('<head>', '<head><base href="../../" />')
|
||||
.replace('<script type="module" src="./main.js"></script>', '<script src="./film/ui/website.js" defer></script>')
|
||||
.replace('</body>', (await readFile(new URL("film/ui/index.html", source), "utf8")).match(/<svg id="cursor"[\s\S]*?<\/svg>/)[0] + '</body>'),
|
||||
@@ -30,10 +71,6 @@ await writeFile(new URL("film/ui/website.html", output),
|
||||
// Static controls use the product icon owner without shipping another React
|
||||
// runtime to the film page. The live demonstration remains its isolated bundle.
|
||||
const filmPage = new URL("film/index.html", output);
|
||||
const { Glyph } = await tsImport("../src/client/ui/icons.tsx", {
|
||||
parentURL: import.meta.url,
|
||||
tsconfig: fileURLToPath(new URL("../src/tsconfig.json", import.meta.url)),
|
||||
});
|
||||
await writeFile(filmPage, (await readFile(filmPage, "utf8")).replace(
|
||||
/<span data-glyph="([a-zA-Z]+)"><\/span>/g,
|
||||
(_, name) => renderToStaticMarkup(createElement(Glyph, { name, size: 21 })),
|
||||
|
||||
+12
-12
@@ -1,34 +1,33 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import semver from "semver";
|
||||
import { isReleaseVersion, stableTags } from "./release-version.mjs";
|
||||
import { isReleaseVersion, releaseCommits, stableTags } from "./release-version.mjs";
|
||||
|
||||
// The reviewed PR body survives the squash commit. Only its public section is
|
||||
// release copy; implementation details and verification logs stay in the PR.
|
||||
export function releaseNotes(root, version, revision, repository) {
|
||||
export function releaseNotes(root, version, revision, repository, { allowEmpty = false } = {}) {
|
||||
if (!isReleaseVersion(version) || !/^[a-f0-9]{40}$/.test(revision) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
||||
throw new Error("Release notes require a stable version, full SHA and owner/repository");
|
||||
}
|
||||
// Exclude this release's tag so retrying after publication keeps the same range.
|
||||
const previous = stableTags(root, "--merged", revision).find((tag) => semver.lt(tag, version));
|
||||
const log = execFileSync("git", ["log", "--first-parent", "--reverse", "--format=%H%x00%B%x00",
|
||||
...(previous ? [`${previous}..${revision}`] : ["-1", revision])], { cwd: root, encoding: "utf8" });
|
||||
const records = log.split("\0");
|
||||
const bodies = [];
|
||||
for (let index = 0; index + 1 < records.length; index += 2) {
|
||||
const sections = records[index + 1].replace(/\r\n/g, "\n").split(/^## /m).slice(1)
|
||||
for (const commit of releaseCommits(root, revision, previous)) {
|
||||
const sections = commit.message.replace(/\r\n/g, "\n").split(/^## /m).slice(1)
|
||||
.filter((section) => section.split("\n", 1)[0].trim() === "Release notes");
|
||||
const body = sections.length === 1
|
||||
? sections[0].split("\n").slice(1).join("\n").replace(/<!--[\s\S]*?-->/g, "").trim()
|
||||
: "";
|
||||
if (!body || !body.split("\n").some((line) => line.trim() && !/^#{1,6}(\s|$)/.test(line))) {
|
||||
throw new Error(`Commit ${records[index].trim()} needs one nonempty ## Release notes section`);
|
||||
throw new Error(`Commit ${commit.revision} needs one nonempty ## Release notes section`);
|
||||
}
|
||||
bodies.push(body);
|
||||
}
|
||||
if (!bodies.length) throw new Error("No release changes found");
|
||||
if (!bodies.length) {
|
||||
if (allowEmpty) return "";
|
||||
throw new Error("No release changes found");
|
||||
}
|
||||
const url = `https://github.com/${repository}`;
|
||||
const source = previous
|
||||
? `[完整变更 / Full changelog](${url}/compare/${previous}...${revision})`
|
||||
@@ -42,10 +41,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
throw new Error("Use release-notes.mjs <version> <full-SHA> [--github-summary]");
|
||||
}
|
||||
const body = releaseNotes(process.cwd(), version, revision,
|
||||
process.env.GITHUB_REPOSITORY || "TNTcraftHIM/Piik");
|
||||
process.env.GITHUB_REPOSITORY || "TNTcraftHIM/Piik", { allowEmpty: Boolean(option) });
|
||||
if (option) {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) throw new Error("GITHUB_STEP_SUMMARY is missing");
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Release notes preview: Piik ${version}\n\n${body}`);
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||||
`## Release notes preview: Piik ${version}\n\n${body || "No product changes; this candidate does not publish a release.\n"}`);
|
||||
}
|
||||
process.stdout.write(body);
|
||||
}
|
||||
@@ -11,6 +11,36 @@ export function stableTags(root, ...filter) {
|
||||
return git(root, "tag", ...filter).split("\n").filter(isReleaseVersion).sort(semver.rcompare);
|
||||
}
|
||||
|
||||
// Known standalone surfaces do not change the shipped App/Server. Everything
|
||||
// else stays eligible, including shared UI, public assets, licenses and build
|
||||
// inputs. In particular, check-client.mjs builds the bundled capture runtime.
|
||||
const nonProductPaths = [
|
||||
"site/**", "docs/**", "tests/**", ".agents/**", ".codex/**", ".githooks/**",
|
||||
"*.md", ".gitignore", ".env.example", "vitest.config.ts",
|
||||
"cmd/**/*.md", "internal/**/*.md", "native/**/*.md", "src/**/*.md",
|
||||
".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md", ".github/workflows/website.yml",
|
||||
"scripts/build-website.mjs", "scripts/update-website-hero.mjs", "scripts/check-website-film.js",
|
||||
"scripts/check-docs.mjs", "scripts/check-project-state.*", "scripts/install-hooks.*",
|
||||
"scripts/required-project-paths.txt", "scripts/tsconfig.json",
|
||||
"scripts/release-*.mjs", "scripts/publish-release.mjs", "scripts/mirror-release.mjs",
|
||||
"scripts/*-gate.*", "scripts/*-probe.*", "scripts/browser-*", "scripts/encoded-*",
|
||||
"scripts/client-gate-endpoint.ts", "scripts/embedded-sfu-page.ts", "scripts/peer-assisted-benchmark.ts",
|
||||
"cmd/piik-peer-gate/**",
|
||||
].map((path) => `:(top,glob,exclude)${path}`);
|
||||
|
||||
// Both version selection and public notes use this same set of product commits.
|
||||
// Git handles deletions and moves across the boundary; no workflow path filter
|
||||
// can hide an earlier product change whose publication needs to be retried.
|
||||
export function releaseCommits(root, revision, previous) {
|
||||
const records = git(root, "log", "--first-parent", "--reverse", "--no-renames", "--format=%H%x00%B%x00",
|
||||
...(previous ? [`${previous}..${revision}`, "--", ".", ...nonProductPaths] : ["-1", revision])).split("\0");
|
||||
const commits = [];
|
||||
for (let index = 0; index + 1 < records.length; index += 2) {
|
||||
commits.push({ revision: records[index].trim(), message: records[index + 1].trim() });
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
// One input for packagers: a planned CI version, an exact public tag, or a dev build.
|
||||
export function buildVersion(root, revision, requested = process.env.PIIK_BUILD_VERSION) {
|
||||
const version = requested || stableTags(root, "--points-at", revision)[0] || "development";
|
||||
@@ -34,7 +64,8 @@ export function planRelease(root) {
|
||||
if (previous) {
|
||||
// Main contains one squash commit per accepted PR. A rerun includes any
|
||||
// unreleased commits, so a later fix cannot hide an earlier breaking change.
|
||||
const messages = git(root, "log", "--first-parent", "--format=%B%x00", `${previous}..HEAD`).split("\0");
|
||||
const messages = releaseCommits(root, revision, previous).map((commit) => commit.message);
|
||||
if (!messages.length) return { version: "development", revision, publish: false };
|
||||
const subjects = messages.map((message) => message.trim().split(/\r?\n/, 1)[0]);
|
||||
const breaking = subjects.some((subject) => /^[a-z]+(?:\([^\r\n)]+\))?!: /i.test(subject)) ||
|
||||
messages.some((message) => /^BREAKING[ -]CHANGE: /m.test(message));
|
||||
|
||||
Reference in new issue
Block a user