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:
AAA authored and GitHub committed 2026-09-12 23:01:48 +08:00
1 parent f92162b3bb
commit 4ff970f483
24 files changed
+429 -157

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
## Release notes
<!-- Public copy, automatically published in GitHub Releases. Keep this exact heading; use ### for subsections. Describe user benefits, fixes and any required upgrade action in concise Chinese and English. Omit internal logs, audit prompts and unverified claims. 更新说明将直接公开:写清用户可感知的变化与升级事项,提供简洁的中英文文案。首次发布介绍当前产品,不罗列内部开发历史。 -->
<!-- Required for product-changing PRs; omit this section for standalone website/docs/maintenance work. Public copy, automatically published in GitHub Releases. Keep this exact heading; use ### for subsections. Describe user benefits, fixes and required upgrade actions in concise Chinese and English. Omit internal logs, audit prompts and unverified claims. 产品改动需填写公开的中英文更新说明;仅官网、文档或外围维护可删去此节。 -->
## Verification / 验证
+39 -1
View File
@@ -1,6 +1,12 @@
name: Website
on:
# Wait for CI/packaging to settle so new downloads reach the same site build.
workflow_run:
workflows: [CI]
types: [completed]
branches:
- main
workflow_dispatch:
permissions:
@@ -12,7 +18,9 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/main'
if: >-
github.ref == 'refs/heads/main' &&
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -24,12 +32,42 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v6
with:
ref: main
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- name: Read published downloads
env:
GH_TOKEN: ${{ github.token }}
PIIK_WEBSITE_RELEASE_DATA: ${{ runner.temp }}/website-release.json
run: |
node --input-type=module <<'JS'
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const repository = 'TNTcraftHIM/Piik';
const github = JSON.parse(execFileSync('gh', ['api', `repos/${repository}/releases/latest`], { encoding: 'utf8' }));
let mirror = null;
try {
const api = `https://gitee.com/api/v5/repos/${repository}`;
const read = async path => {
const response = await fetch(api + path, { signal: AbortSignal.timeout(20000) });
if (!response.ok) throw new Error(`Mirror metadata: HTTP ${response.status}`);
return response.json();
};
const release = await read(`/releases/tags/${encodeURIComponent(github.tag_name)}`);
if (!Number.isSafeInteger(release.id) || release.id <= 0) throw new Error('Invalid mirror release ID');
mirror = { ...release, assets: await read(`/releases/${release.id}/attach_files?per_page=100`) };
} catch {
console.log('Mirror metadata unavailable; keeping the mirror release-page link.');
}
writeFileSync(process.env.PIIK_WEBSITE_RELEASE_DATA, JSON.stringify({ github, mirror }));
JS
- run: npm run build:website
env:
PIIK_WEBSITE_RELEASE_DATA: ${{ runner.temp }}/website-release.json
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
with:
+1 -1
View File
@@ -89,7 +89,7 @@
does not need; total complexity matters more than marginal performance gains.
- [Versioning](./docs/reference/versioning.md) separates build identity, wire,
storage and live generations, and owns the public compatibility design.
Its pending public-release policy does not replace today's private contract.
The declared public release is the compatibility baseline for subsequent work.
- Use primary sources for non-trivial design and bugs. Add focused tests in
proportion to risk; batch full browser/network/endurance checks at acceptance.
- Keep scripts deterministic, fast, cross-platform and CI-runnable.
+7 -4
View File
@@ -44,9 +44,10 @@ history without explicit approval.
For deployment work, a routine application-only release verifies a new immutable artifact, switches to it atomically, and guarantees the pre-cutover application release only through bounded health and postflight checks; it has no retention contract afterward and is not a maintained backup. Define recovery only for the infrastructure, configuration, secrets, persistent state, or irreversible surfaces the task actually touches, before changing them.
Use the [version/compatibility policy](./docs/reference/versioning.md) to classify
release changes. In today's private contract an incompatible signaling cutover
forces old pages to reload and ends Browser capture; include that interruption
in the accepted cutover. Compatible UI/internal work does not require a wire bump.
release changes. An incompatible signaling cutover forces old pages to reload
and ends Browser capture; follow the public compatibility promise and include
that interruption in the accepted cutover. Compatible UI/internal work does not
require a wire bump.
Clean up worktrees and branches only after semantic review and integration are complete and the normal merged-head, open-reference, clean-tree, and non-following link checks pass. Never merge an old branch wholesale after a newer truth checkpoint.
@@ -119,12 +120,14 @@ reserve `spike/` for experiments. No issue number is required. 中文说明:PR
A pull request should explain the problem, the chosen design, verification performed, user-visible or operational effects, and remaining risks.
Before an integration PR is accepted, fill its exact `## Release notes` section
Before a product-changing integration PR is accepted, fill its exact `## Release notes` section
with concise public copy in Chinese and English: user-visible changes, fixes and
required upgrade actions. Use `###` for subsections. Review this text as product
copy; it is published automatically from the squash commit, without PR discussion
or verification logs. The [release-note policy](./docs/reference/versioning.md#release-notes)
owns aggregation and preview. Do not add a changelog file or release-note archive.
Standalone website, documentation and maintenance phases do not need product
release notes; their public description stays in the PR.
Durable semantic or current-snapshot changes must update their single owner in the same integration boundary. Ordinary implementation and bug fixes may be complete with code plus focused evidence.
+7 -4
View File
@@ -71,8 +71,8 @@ understandable; a genre label or a score burst does not replace the action.
Use mint walls, clear control edges, a warm orange/yellow sofa and the curious TV
mascot. The site is a welcoming room, not a monitoring dashboard. Keep one
primary action, native navigation and short instructions. English is the first
view with an explicit Chinese choice; theme follows the system unless changed.
primary action, native navigation and short instructions. Language and theme
follow the system unless explicitly changed; unsupported languages use English.
Respect reduced motion and retain useful content without JavaScript.
The room illustration cycles through four distinct uses: an RPG, drawing,
@@ -133,8 +133,11 @@ are not automatically included in the website artifact.
The [website operations guide](../operations/website.md) owns Pages publication,
public destinations and the separate P2P-only demo. The primary action leads
to the platform download section. Use permanent release-page links for downloads;
archive names include a revision. Verify destinations as part of publication.
to the platform download section. Build static package links from a published
GitHub release, showing its version and each supported platform. Gitee links must
match that release and its source revision. Keep release notes separate; when a
package is unverified, label the fallback as a release-page link. Archive names
include a revision. Verify destinations as part of publication.
Demo, App and existing-site walkthroughs
use real action names and explain the first sharing and viewing steps. Keep
platform requirements and connection limitations beside the relevant steps.
+7 -5
View File
@@ -30,9 +30,11 @@ copy and repository visibility; finish main protection. Then set repository
Actions variable `PIIK_RELEASES_ENABLED` to `true`. This is a repository setting,
not an environment variable for Piik App or Server.
Subsequent accepted PRs merged to main use the existing CI workflow: calculate
one version, validate, package Server and all App targets, and publish the same
artifacts. Runs queue rather than overlap or cancel one another. Branches/PRs
Subsequent accepted PRs merged to main use the existing CI workflow. Product
changes calculate one version, validate, package Server and all App targets,
and publish the same artifacts. Standalone website/docs changes validate without
packaging or releasing; [versioning](../reference/versioning.md#automatic-publication)
owns that boundary. Runs queue rather than overlap or cancel one another. Branches/PRs
stay quiet; manual `client_checks` dispatch remains available for candidates.
Writing a new version back to main is unnecessary and prohibited.
@@ -84,8 +86,8 @@ Use the release's actual version and full `target_commitish` SHA, with
`GITEE_TOKEN` supplied through the publisher environment. `--dry-run` after the
three arguments checks local identity, checksums and attachment sizes without
publishing. The [release-source policy](../reference/versioning.md#release-sources)
owns provenance, selection and quota limits. Small-file upload/download acceptance
has passed; full-package mirror acceptance remains part of release readiness.
owns provenance, selection and quota limits. The first public release passed
complete matching Server and three-platform App mirror download acceptance.
## Platform References
+24 -9
View File
@@ -14,8 +14,10 @@ its content and design boundary.
| Download mirror for domestic access | [Gitee Releases](https://gitee.com/TNTcraftHIM/Piik/releases) |
| Documentation index | [Repository documentation](../README.md) |
The introduction is prepared with these links. DNS, deployment and release
publication must be verified when publishing.
These public destinations are live. GitHub Pages serves `piik.tv` behind
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website DNS is
proxied with strict origin TLS verification and origin cache headers respected.
Demo DNS remains unproxied so its UDP listeners stay reachable.
GitHub Pages can serve the complete website: it hosts
[static HTML, CSS and JavaScript](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages).
@@ -71,7 +73,16 @@ film's real product components into an isolated demonstration frame; publishing
ranges for seeking the soundtrack. Keep asset links relative so both a custom
domain and GitHub's `/Piik/` project path work.
## Publish when approved
Downloads are static links to the latest published GitHub packages, with explicit
Windows x64, macOS Apple silicon and Linux x64 cards. `PIIK_WEBSITE_RELEASE_DATA`
can point the build at a temporary JSON containing the GitHub release and the
same-tag Gitee release with its attachment list. CI supplies this input; the
builder validates version, source identity and platform URLs. A missing/incomplete
GitHub release fails deployment, preserving the current site. An unavailable
mirror retains a labeled release-page link. Local builds without metadata use
release-page links as well; no visitor API call or hard-coded package SHA is needed.
## Publish
1. Verify the public copy and destinations above, publish the release artifacts
and open the source repository as part of the approved public launch.
@@ -81,13 +92,17 @@ domain and GitHub's `/Piik/` project path work.
3. Set the approved `piik.tv` custom domain in Pages settings and configure its
DNS using GitHub's [current domain instructions](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site).
Actions publishing does not need a `CNAME` file in the source tree.
4. Dispatch **Website** from `main` after the reviewed site is merged. The
workflow builds and uploads only `build/site/`; no App/Server packaging is run. Verify the
domain, HTTPS, both languages, relative asset paths and primary links.
4. Accepted main pushes run CI first; its completion triggers **Website**, which
checks out current main and reads the latest published download metadata.
Waiting for CI includes its package publication and mirror attempt, so the
page picks up the resulting release. A CI failure leaves downloads pointing
to the latest successful public release. Manual **Website** dispatch from
`main` can refresh links after an operator publication or mirror retry.
Only `build/site/` is uploaded; website work does not package App/Server.
Verify HTTPS, both languages, relative assets and per-platform download links.
There is no automatic deployment on branch pushes. The workflow is prepared,
but writing it does not enable Pages or change DNS. The private production
site and the US P2P-only demo remain separate deployments.
Branch/PR pushes do not deploy. The workflow does not change DNS; the private
production site and the US P2P-only demo remain separate deployments.
For rollback, dispatch the previously accepted site revision from a reviewed
revert on `main`; runtime App/Server binaries and databases are unaffected.
+33 -32
View File
@@ -1,10 +1,9 @@
# Versions And Compatibility
Reviewed 2026-09-12. Release identity, update comparisons and descriptive metadata
rules are implemented; the public compatibility promise starts with the first
declared public release. Use `v1.0.0` for it, with one product version for App,
Server and the embedded Web build, plus the full Git SHA for source provenance.
Automatic publication remains disabled until the readiness boundary below.
Reviewed 2026-09-12. `v1.0.0` is the declared public compatibility baseline.
App, Server and the embedded Web build share one product version, plus the full
Git SHA for source provenance. [Status](../status.md) owns the current publication
state; automatic publication follows the product-change boundary below.
## Give Each Identifier One Job
@@ -101,16 +100,27 @@ identity separately. Stable update notices ignore prereleases.
## Automatic Publication
After one-time activation, a complete squash PR merged to main runs the existing
CI and packaging pipeline. `scripts/release-version.mjs` examines unreleased
first-parent commit messages: `feat` selects minor, `!` or `BREAKING CHANGE`
selects major, and other accepted changes select patch. The first release is
`v1.0.0`. PR/commit naming is owned by [CONTRIBUTING](../../CONTRIBUTING.md#commit-pr-and-branch-names).
CI pipeline. `scripts/release-version.mjs` filters unreleased first-parent
commits by actual changed paths. Website/film, documentation, research, agent
instructions and standalone maintenance tools do not create product versions.
Shared product UI, runtime code, public assets, licenses, dependencies and
packaging inputs remain eligible; unknown paths are treated as product inputs.
The script owns the exclusion list, shared with release-note generation.
Only eligible commit messages select the increment: `feat` selects minor,
`!` or `BREAKING CHANGE` selects major, and other accepted product changes select
patch. A website-only `feat` cannot increase the next product version. Earlier
unpublished product changes remain eligible when a later website commit reaches
main. PR/commit naming is owned by [CONTRIBUTING](../../CONTRIBUTING.md#commit-pr-and-branch-names).
Code size or an internal refactor does not itself imply a breaking release.
No version/changelog commit is written back to main. The workflow queues main
runs, uses the same source SHA for Server and all App targets, and publishes only
after all required artifacts and checks pass. The publisher verifies matching
version, source SHA and checksums, then creates/uploads/publishes one GitHub draft.
When there are no unpublished product changes, CI validates a development build
and skips packaging and publication; it never assigns an old release version to
a new source SHA. The separate Website workflow updates the static site after CI.
Failed draft uploads can resume; published artifacts are never overwritten.
An older draft retried after a newer release cannot take over `latest`.
@@ -129,19 +139,22 @@ introduced by this policy.
## Release Notes
GitHub Releases owns the published changelog. Each accepted PR supplies reviewed
GitHub Releases owns the published changelog. Each product-changing PR supplies reviewed
Chinese and English user-facing copy under the exact `## Release notes` heading
in its squash commit; `###` headings structure that copy. Explain benefits,
fixes and required upgrade actions, including breaking changes. Keep internal
work logs, audit handoffs and unverified claims outside this public section.
`scripts/release-notes.mjs` collects these sections from all unreleased
first-parent commits since the previous stable tag. The first release uses
`scripts/release-notes.mjs` collects these sections from the same eligible
first-parent commits since the previous stable tag. Standalone website and
documentation phases need no product release notes. The first release uses
only its launch commit's product introduction; private development history is
not a launch changelog. Missing, empty or duplicate sections fail validation
before packaging/publication. GitHub Actions shows the generated text in its
run summary. A manual candidate with an explicit version also previews it, so
prepare its commit text before dispatching a release rehearsal.
prepare its commit text before dispatching a release rehearsal. A manual
candidate with no product changes may still be built; its summary reports that
there are no product release notes, and publication remains disabled.
The publisher passes the same text through a temporary notes file to GitHub,
then removes that file. Gitee copies the GitHub description. Retrying the same
@@ -189,29 +202,17 @@ Gitee currently documents 100 MB per attachment and 1 GB total repository
attachments for ordinary projects. The mirror rejects files above 100,000,000
bytes before writing. Check aggregate capacity before each release; exhausted
storage leaves a pending preview rather than deleting older downloads.
Target authentication, uploads, anonymous metadata/CORS and a small download's
SHA-256 have been verified. Acceptance of the full matching release packages
remains pending with the first public release.
Target authentication, uploads, anonymous metadata/CORS and the complete matching
Server/three-platform App downloads and SHA-256 checks passed for the public release.
Use API metadata in the Browser; ordinary asset-link redirects are not guaranteed
to permit cross-origin fetch, even when a normal download works.
## First Public Release Readiness
## Public Release Baseline
The current private single-contract rule remains until this boundary is ready:
1. Accept real target packages and the queued GitHub publication path before
enabling automatic releases. Current local checks exercise planning, identity
injection, update decisions and publisher failure/retry paths without publishing.
2. Freeze the first public baseline and explicit feature-support rules after
checking the implemented discovery and descriptive-metadata behavior above.
3. Exercise the first supported old/new App-Site pair in both directions,
a stale open page, incompatible-contract recovery and retained room/config
authority. Bundled capture checks remain within their existing package gate.
4. Freeze the supported baseline and release notes, then explicitly declare
`v1.0.0`. Do not manufacture compatibility by merely renumbering private builds.
These implementation tasks are tracked in [TODO](../todo.md). Their absence is
a public-release gap, not evidence that the current matched private build fails.
The declared `v1.0.0` release starts the compatibility promise above. Its packages,
reviewed notes and descriptors are immutable. Subsequent releases preserve this
baseline through compatible upgrades; the remaining physical device/network
limits stay in [verification status](../verification-status.md).
## References
+13 -12
View File
@@ -38,23 +38,24 @@ media remain process-only. This private service is not a public demonstration.
The separate US [public demo](https://demo.piik.tv) is deployed with open site
entry, persistent room authority and P2P-only media. Caddy owns HTTPS, and the
standard systemd service runs the verified Server test candidate. Public DNS,
standard systemd service runs the published Server package. Public DNS,
HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P
sharing/stop check passed; the browser check used synthetic video on one network.
The operator record owns exact identity and postflight evidence.
[TODO](./todo.md) owns remaining publication work. The owner accepted the public
introduction and authorized launch on 2026-09-12. The source repository is public,
main protection is active, and GitHub Pages is configured for `piik.tv`. The
private service remains a separate deployment. Release descriptors and GitHub
deployment records identify published artifacts and website revisions.
[TODO](./todo.md) owns the current maintenance phase. The owner declared the first
public release on 2026-09-12. Source is public, main protection is active, and
`piik.tv` runs on GitHub Pages behind Cloudflare with HTTPS. The private service
remains a separate deployment. Release descriptors and GitHub deployment records
identify published artifacts and website revisions.
The Windows test archive passed complete anonymous Gitee download, checksum,
App entry/presentation and update-link acceptance. Bounded two-build
interoperability also passed. The complete publisher rehearsal previously hit
the private repository's Actions artifact quota and must complete on the public
repository before package publication. GitHub/Gitee release comparison, mirror
publishing and PR-sourced release notes are implemented under
The matching Server and three App packages are published on GitHub and Gitee;
anonymous mirror downloads match the original checksums. Public-repository CI
and native packaging passed. App entry, update links
and bounded two-build interoperability have acceptance evidence. Accepted
product changes publish automatically; standalone website and documentation
changes validate and deploy the website without creating App/Server versions.
GitHub/Gitee release comparison, mirror publishing and PR-sourced notes follow
[versioning](./reference/versioning.md). [Verification status](./verification-status.md#candidate-evidence-boundary)
owns the remaining physical limits; shared lessons live in
[engineering](./reference/engineering.md).
+4 -22
View File
@@ -7,30 +7,12 @@ history. A parked idea is not implementation authority.
## Now
- [ ] **Release and repository readiness.** Review the prepared Windows test
candidate. Run native CI on the now-public repository, then
rehearse the publisher with genuine matching Server and App packages. Old cloud
artifacts have already been removed; if quota rejection persists, inspect
account billing and Packages usage with credentials that can read those scopes.
Run the final source's required regression checks, prepare one coherent squash
PR, accept the [first public release](./reference/versioning.md#first-public-release-readiness),
and finish [publishing activation](./operations/github.md).
Test-candidate checks do not accept the public release. Keep the owner's
- [ ] **Post-launch monitoring.** Collect App/Server feedback and verify the
public download path after product releases. Preserve the owner's
[device/network deferrals](./verification-status.md#candidate-evidence-boundary).
- [ ] **Gitee package acceptance.** The public README mirror, publisher secret,
upload pipeline and App/Server update fallback are implemented. The complete
Windows test archive passed anonymous download, checksum and App entry
acceptance. Verify the complete matching Server and three-platform
App release, total attachment capacity and real update/download flow
under the [release model](./reference/versioning.md#release-sources) before launch.
- [ ] **Website launch.** Publish the accepted website through GitHub Pages
and verify piik.tv under
[website operations](./operations/website.md). Verify all public destinations,
including the deployed password-free, P2P-only demo.piik.tv;
the current private production service remains private.
Keep implementation on the existing phase branch. Main and the private service stay
unchanged until the related acceptance and delivery actions are complete.
Keep fixes on a maintenance branch until acceptance. The public release is the
compatibility baseline; private service deployment stays independent.
## Next: P2P Connection And Feedback Review
+5 -6
View File
@@ -1,6 +1,6 @@
# Verification Status
Last updated: 2026-09-11
Last updated: 2026-09-12
This file owns physical evidence limits that change how the product may be
described. [Status](./status.md) owns the execution/deployment index.
@@ -57,11 +57,10 @@ Linux release archives. Process replacement used a forced stop on Windows and
does not promise uninterrupted media. It does not cover game/audio endurance,
storage faults, the wider device/network matrix or Go race detection.
The full publisher dry run with genuine matching target artifacts remains
unverified. The native CI candidate run passed validation and Server packaging
but hit the Actions artifact-storage quota before App jobs could start. Native
macOS tooling remains necessary. [TODO](./todo.md) owns the remaining publisher
rehearsal and owner acceptance; the broader platform deferrals below still apply.
The first public release passed real Server/three-platform App packaging and
GitHub/Gitee publication, including anonymous mirror checksum verification.
These packaging checks do not establish physical macOS/Linux capture or expand the device/network evidence
below. [Status](./status.md) owns current publication and activation state.
## Remaining Device And Network Acceptance
+42 -5
View File
@@ -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
View File
@@ -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);
}
+32 -1
View File
@@ -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));
-1
View File
@@ -42,7 +42,6 @@ export function gameMarkup(prefix) {
<g id="${prefix}-player" style="color:${INK}"><g transform="translate(-36 -44) scale(2.4)"><rect x="1" y="13" width="10" height="14" rx="3" fill="#db8755"/>${mascotMarkup(prefix+'-hero','#faf5e7')}</g></g>
<g id="${prefix}-treasure"><circle r="53" fill="#fff3bd" opacity=".22"/><path d="m0-31 24 31-24 31-24-31Z" fill="#ffe391" stroke="#bb8537" stroke-width="3"/><path d="m0-31 0 62-24-31Z" fill="#fff5cd"/><path d="m-45-8-12-6m11 37-10 9m102-40 12-6m-11 37 10 9M0-55v-12" stroke="#fff5ce" stroke-width="5" stroke-linecap="round"/></g>
${tree(1435,779,1.6)}${tree(744,952,1.22)}
<g transform="translate(63 56)"><rect width="253" height="56" rx="28" fill="${INK}"/><text x="126.5" y="36" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" font-size="22" font-weight="750" fill="#faf5e7" letter-spacing="2">LITTLE WANDER</text></g>
<g transform="translate(1415 94)" stroke="#faf5e7" stroke-width="4" fill="none"><circle r="32"/><path d="m0-19 8 19-8 19-8-19Z" fill="#faf5e7"/><path d="M0-41v-8m41 49h8m-49 41v8m-41-49h-8"/></g>`;
}
-1
View File
@@ -79,7 +79,6 @@
</g></g></g></g>
<g id="hero-rpg-treasure"><circle r="53" fill="#fff3bd" opacity=".22"/><path d="m0-31 24 31-24 31-24-31Z" fill="#ffe391" stroke="#bb8537" stroke-width="3"/><path d="m0-31 0 62-24-31Z" fill="#fff5cd"/><path d="m-45-8-12-6m11 37-10 9m102-40 12-6m-11 37 10 9M0-55v-12" stroke="#fff5ce" stroke-width="5" stroke-linecap="round"/></g>
<g transform="translate(1435 779) scale(1.6)"><ellipse cy="65" rx="73" ry="23" fill="#31594b" opacity=".18"/><path d="M-7 9h14v57H-7Z" fill="#816947"/><path d="M-69 12q-2-48 38-46 6-66 54-38 43-3 40 40 42 36 4 65-24 24-57 3-46 13-79-24Z" fill="#428570"/><path d="M-58-6q-9-32 29-32 11-45 44-34 37 8 35 35-21 29-47 14-20 36-61 17Z" fill="#72aa7e"/></g><g transform="translate(744 952) scale(1.22)"><ellipse cy="65" rx="73" ry="23" fill="#31594b" opacity=".18"/><path d="M-7 9h14v57H-7Z" fill="#816947"/><path d="M-69 12q-2-48 38-46 6-66 54-38 43-3 40 40 42 36 4 65-24 24-57 3-46 13-79-24Z" fill="#428570"/><path d="M-58-6q-9-32 29-32 11-45 44-34 37 8 35 35-21 29-47 14-20 36-61 17Z" fill="#72aa7e"/></g>
<g transform="translate(63 56)"><rect width="253" height="56" rx="28" fill="#203037"/><text x="126.5" y="36" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" font-size="22" font-weight="750" fill="#faf5e7" letter-spacing="2">LITTLE WANDER</text></g>
<g transform="translate(1415 94)" stroke="#faf5e7" stroke-width="4" fill="none"><circle r="32"/><path d="m0-19 8 19-8 19-8-19Z" fill="#faf5e7"/><path d="M0-41v-8m41 49h8m-49 41v8m-41-49h-8"/></g></g>
<g id="activity-drawing">
<rect width="1600" height="900" fill="#f3cc68"/>

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

+38
View File
@@ -0,0 +1,38 @@
// Build-time download links. GitHub owns release identity; Gitee mirrors it.
import { parseMirrorReleaseMetadata, parseReleaseMetadata } from "../src/client/lib/release-update";
const targets = ["windows-amd64", "darwin-arm64", "linux-amd64"] as const;
function assets(value: unknown): Record<string, unknown>[] {
const list = value && typeof value === "object" ? (value as Record<string, unknown>).assets : null;
return Array.isArray(list)
? list.filter((asset): asset is Record<string, unknown> => asset !== null && typeof asset === "object" && !Array.isArray(asset))
: [];
}
export function websiteDownloads(data: { github: unknown; mirror?: unknown }) {
const release = parseReleaseMetadata(data.github);
if (!release?.revision) throw new Error("Website downloads require a published GitHub release with a full source SHA");
const primaryAssets = assets(data.github);
const mirror = parseMirrorReleaseMetadata(data.mirror);
const mirrorAssets = mirror?.version === release.version && mirror.revision === release.revision
? assets(data.mirror) : [];
return {
version: release.version,
notes: release.url,
packages: targets.map(target => {
const name = `piik-app-${target}-${release.revision!.slice(0, 7)}.tar.gz`;
const url = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const matches = primaryAssets.filter(asset => asset.name === name);
const asset = matches[0];
if (matches.length !== 1 || asset?.state !== "uploaded" || asset.browser_download_url !== url ||
!Number.isSafeInteger(asset.size) || Number(asset.size) <= 0) {
throw new Error(`Website download is missing or invalid: ${target}`);
}
const mirrorURL = `https://gitee.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
const mirrored = mirrorAssets.filter(candidate => candidate.name === name);
return { target, url, mirrorURL: mirrored.length === 1 && mirrored[0]!.size === asset.size &&
mirrored[0]!.browser_download_url === mirrorURL ? mirrorURL : null };
}),
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ to static markup during the website build. Its score and audio remain owned by
in picture remain in `PlaybackControls`. A shared appearance does not require
giving a recorded sequence live-room state.
[`../assets/game.js`](../assets/game.js) owns the original **LITTLE WANDER** RPG
[`../assets/game.js`](../assets/game.js) owns the original RPG
scene. The film and demonstration TV render it using the film clock.
The film's six game vignettes share pure poses in `../assets/games.js`.
`../assets/activities.js` composes the homepage's RPG, drawing, photos and movie
+1 -1
View File
@@ -77,7 +77,7 @@ const sources = [
sourceId: "101",
pid: 1,
creationTime: "1",
title: "Little Wander",
title: "RPG",
},
{
kind: "window" as const,
+26 -28
View File
@@ -108,7 +108,7 @@
/>
<div class="scene-caption">
<span class="caption-line"></span>
<p><span lang="en">“I saved you a seat.”</span><span lang="zh-CN">“沙发给你留着呢。”</span></p>
<p id="welcome-line"><span lang="en">“Your seat is right here.”</span><span lang="zh-CN">“沙发给你留着呢。”</span></p>
</div>
</div>
</section>
@@ -716,11 +716,8 @@
</p>
</div>
<p class="release-note" id="download-status">
<span class="release-label">GitHub · Gitee</span
><span lang="en"
>On the release page, choose a file beginning with piik-app for your system. Read the release notes and
follow the App guide.</span
><span lang="zh-CN">在发布页下载以 piik-app 开头、对应系统的客户端程序包,查看版本说明,再按客户端教程开始分享。</span>
<span lang="en">Choose Piik App for your system on the release page.</span>
<span lang="zh-CN">在发布页选择对应系统的 Piik App 程序包。</span>
</p>
<div class="download-cards">
<article class="download-card" aria-labelledby="download-windows">
@@ -731,18 +728,17 @@
</div>
<h3 id="download-windows">Windows</h3>
<p>
<span lang="en">For x64 PCs. Choose the package marked</span
><span lang="zh-CN">适用于 x64 电脑。请选择文件名含</span> <code>windows-amd64</code
><span lang="en">.</span><span lang="zh-CN"> 的程序包。</span>
<span lang="en">For x64 PCs.</span><span lang="zh-CN">适用于 x64 电脑。</span>
</p>
<a
class="button download-button"
data-download="windows-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">GitHub download</span
><span lang="zh-CN">GitHub 下载</span></a
><a href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">Gitee mirror</span><span lang="zh-CN">Gitee 国内镜像</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><a data-download="windows-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
</article>
<article class="download-card" aria-labelledby="download-macos">
@@ -761,18 +757,18 @@
</div>
<h3 id="download-macos">macOS</h3>
<p>
<span lang="en">Apple silicon; native capture needs macOS 13+. Choose the package marked</span
><span lang="zh-CN">适用于 Apple 芯片;原生采集需 macOS 13 及以上。请选择文件名含</span> <code>darwin-arm64</code
><span lang="en">.</span><span lang="zh-CN"> 的程序包。</span>
<span lang="en">Apple silicon. Native capture needs macOS 13+.</span
><span lang="zh-CN">适用于 Apple 芯片。原生采集需 macOS 13 及以上。</span>
</p>
<a
class="button download-button"
data-download="darwin-arm64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">GitHub download</span
><span lang="zh-CN">GitHub 下载</span></a
><a href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">Gitee mirror</span><span lang="zh-CN">Gitee 国内镜像</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><a data-download="darwin-arm64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
</article>
<article class="download-card" aria-labelledby="download-linux">
@@ -791,23 +787,25 @@
</div>
<h3 id="download-linux">Linux</h3>
<p>
<span lang="en">For x64 PCs. Choose the package marked</span
><span lang="zh-CN">适用于 x64 电脑。请选择文件名含</span> <code>linux-amd64</code><span lang="en">.</span
><span lang="zh-CN"> 的程序包。</span>
<span lang="en">For x64 PCs.</span><span lang="zh-CN">适用于 x64 电脑。</span>
</p>
<a
class="button download-button"
data-download="linux-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">GitHub download</span
><span lang="zh-CN">GitHub 下载</span></a
><a href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">Gitee mirror</span><span lang="zh-CN">Gitee 国内镜像</span></a
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">View GitHub releases</span
><span lang="zh-CN">查看 GitHub 版本</span></a
><a data-download="linux-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases"
><span lang="en">View Gitee mirror</span><span lang="zh-CN">查看 Gitee 镜像</span></a
>
</article>
</div>
<div class="download-help">
<a href="#guide-app"><span lang="en">Piik App setup guide</span><span lang="zh-CN">客户端使用教程</span><span aria-hidden="true"> →</span></a>
<div class="download-links">
<a class="button" href="https://github.com/TNTcraftHIM/Piik/releases"><span lang="en">More downloads</span><span lang="zh-CN">更多下载</span><span aria-hidden="true"> ↗</span></a>
<a class="text-link" href="#guide-app"><span lang="en">Piik App setup guide</span><span lang="zh-CN">客户端使用教程</span><span aria-hidden="true"> →</span></a>
</div>
<p><span lang="en">The macOS and Linux apps have not yet been tested on physical devices. If you have a device, we'd welcome your <a href="https://github.com/TNTcraftHIM/Piik/issues">test results and feedback</a>.</span><span lang="zh-CN">macOS 和 Linux 客户端尚未经过实机测试,欢迎有设备的朋友试用并<a href="https://github.com/TNTcraftHIM/Piik/issues">反馈问题</a>。</span></p>
</div>
</section>
+5
View File
@@ -10,6 +10,11 @@ const roomIllustration = document.querySelector('.living-room');
const film = document.getElementById('film-preview');
const filmFrame = document.getElementById('website-film');
const filmLink = document.querySelector('[data-film-link]');
const welcomeLines = document.getElementById('welcome-lines')?.content.children;
if (welcomeLines?.length) {
const line = Math.floor(Math.random() * welcomeLines.length);
document.getElementById('welcome-line').replaceChildren(welcomeLines[line].cloneNode(true));
}
function syncFilmPreferences() {
const target = new URL(filmLink.href);
target.searchParams.set('lang', root.lang);
+3 -2
View File
@@ -86,12 +86,13 @@ h1 { font-size: clamp(49px, 5.7vw, 76px); line-height: 1.06; margin-block: 22px
.film-collapse { margin-left:auto; font-size:12px; color:var(--muted); }
.film-preview iframe { display:block; width:100%; height:calc(56.25cqw + 144px); border:0; }
.download-help { margin-top:22px; text-align:center; }
.download-help > a { display:inline-flex; align-items:center; min-height:44px; font-size:14px; font-weight:650; }
.download-links { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px 24px; margin-bottom:14px; }
.download-help p { max-width:760px; margin:6px auto 0; color:var(--muted); font-size:13px; }
.hero-scene { min-width: 0; }
.living-room { display: block; width: 108%; height: auto; max-width: none; margin-left: -5%; }
.scene-caption { display: flex; justify-content: center; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); margin-top: 2px; }
.caption-line { width: 23px; height: 1px; background: var(--orange); transform: rotate(-6deg); }
.scene-caption p { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.caption-line { flex: none; width: 23px; height: 1px; background: var(--orange); transform: rotate(-6deg); }
.how-section { padding-block: 67px 63px; background: var(--paper); border-block: 1px solid var(--line); }
.section-heading { text-align: center; }
.section-heading .eyebrow { justify-content: center; }
+75 -8
View File
@@ -1,8 +1,8 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { releaseNotes } from "../scripts/release-notes.mjs";
@@ -15,8 +15,11 @@ describe("release automation", () => {
it("publishes reviewed notes, excludes private history and retains every unreleased phase on retries", () => {
const root = mkdtempSync(join(tmpdir(), "piik-release-notes-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
const commit = (message: string) => {
git("commit", "--allow-empty", "-m", message);
const commit = (message: string, path = "index.html") => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), message);
git("add", "--", path);
git("commit", "-m", message);
return git("rev-parse", "HEAD");
};
try {
@@ -33,12 +36,20 @@ describe("release automation", () => {
expect(first).not.toMatch(/private history|Internal|Editor guidance/);
git("tag", "-a", "v1.0.0", "-m", "Launch");
expect(releaseNotes(root, "v1.0.0", launch, "fixture/Piik")).toBe(first);
const website = commit("feat(site)!: redesign the homepage", "site/index.html");
expect(() => releaseNotes(root, "v1.0.1", website, "fixture/Piik")).toThrow("No release changes");
expect(releaseNotes(root, "v1.0.1", website, "fixture/Piik", { allowEmpty: true })).toBe("");
const candidate = JSON.parse(execFileSync(process.execPath, [planner, "build"], {
cwd: root, encoding: "utf8", env: { ...process.env, PIIK_BUILD_VERSION: "v1.0.1" },
}));
expect(candidate).toMatchObject({ version: "v1.0.1", publish: false });
commit("fix: repair playback\n\n## Release notes\n- Playback resumes after reconnecting.");
const next = commit("feat: add a sharing option\n\n## Release notes\n- Choose a window to share.");
const notes = releaseNotes(root, "v1.1.0", next, "fixture/Piik");
expect(notes).toContain("- Playback resumes after reconnecting.\n\n- Choose a window to share.");
expect(notes).toContain(`/compare/v1.0.0...${next}`);
expect(notes).not.toContain("开始分享");
expect(notes).not.toContain("redesign");
git("tag", "v1.1.0");
expect(releaseNotes(root, "v1.1.0", next, "fixture/Piik")).toBe(notes);
// A checkout may have advanced; generation still uses the requested SHA.
@@ -56,12 +67,18 @@ describe("release automation", () => {
const root = mkdtempSync(join(tmpdir(), "piik-release-plan-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
const plan = () => JSON.parse(execFileSync(process.execPath, [planner, "plan"], { cwd: root, encoding: "utf8" }));
const commit = (message: string, path = "index.html") => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), message);
git("add", "--", path);
git("commit", "-m", message);
};
try {
git("init", "--quiet", "--initial-branch=main");
git("config", "user.name", "Piik fixture");
git("config", "user.email", "fixture@example.invalid");
git("config", "commit.gpgSign", "false");
git("commit", "--allow-empty", "-m", "chore: initial");
commit("chore: initial");
expect(plan().version).toBe("v1.0.0");
const candidate = JSON.parse(execFileSync(process.execPath, [planner, "build"], {
cwd: root, encoding: "utf8", env: { ...process.env, PIIK_BUILD_VERSION: "v1.0.0" },
@@ -71,11 +88,22 @@ describe("release automation", () => {
git("tag", "v1.0.0");
// The tag might belong to an unfinished draft: publication can resume.
expect(plan()).toMatchObject({ version: "v1.0.0", publish: true });
git("commit", "--allow-empty", "-m", "fix: repair playback\n\nExample text:\nfeat: is not this commit's type");
for (const path of ["site/film/art.js", "docs/deployment.md", "README.zh-CN.md",
"scripts/build-website.mjs", "scripts/check-docs.mjs", "scripts/release-notes.mjs",
"tests/room.test.ts", ".agents/skills/ponytail/SKILL.md", ".github/workflows/website.yml"]) {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), "Peripheral content");
}
git("add", ".");
git("commit", "-m", "feat(site)!: redesign the website\n\nBREAKING CHANGE: website layout only");
expect(plan()).toMatchObject({ version: "development", publish: false });
commit("fix: repair playback\n\nExample text:\nfeat: is not this commit's type");
expect(plan().version).toBe("v1.0.1");
git("commit", "--allow-empty", "-m", "feat(app): add a sharing option");
commit("feat(site)!: refresh the film", "site/film/art.js");
expect(plan()).toMatchObject({ version: "v1.0.1", revision: git("rev-parse", "HEAD"), publish: true });
commit("feat(app): add a sharing option");
expect(plan().version).toBe("v1.1.0");
git("commit", "--allow-empty", "-m", "refactor!: replace the public contract");
commit("refactor!: replace the public contract");
expect(plan().version).toBe("v2.0.0");
expect(git("tag")).toBe("v1.0.0");
const reused = spawnSync(process.execPath, [planner, "build"], {
@@ -86,6 +114,45 @@ describe("release automation", () => {
} finally { rmSync(root, { recursive: true, force: true }); }
});
it("keeps shipped assets, build inputs and moves across the website boundary release-worthy", () => {
const root = mkdtempSync(join(tmpdir(), "piik-release-paths-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
const plan = () => JSON.parse(execFileSync(process.execPath, [planner, "plan"], { cwd: root, encoding: "utf8" }));
try {
git("init", "--quiet", "--initial-branch=main");
git("config", "user.name", "Piik fixture");
git("config", "user.email", "fixture@example.invalid");
git("config", "commit.gpgSign", "false");
mkdirSync(join(root, "public"));
writeFileSync(join(root, "public", "asset.svg"), "Shared artwork");
git("add", ".");
git("commit", "-m", "feat: launch");
git("tag", "v1.0.0");
for (const path of ["src/client/styles.css", "src/shared/protocol.ts", "internal/server/app/app.go",
"cmd/piik-app/piik.ico", "native/capture/windows/build.ps1", "public/guide.md",
"licenses/NOTICE.md", "LICENSE", "package-lock.json", "go.mod",
"scripts/check-client.mjs", "scripts/client-package-targets.mjs", ".github/workflows/repository-hygiene.yml",
"new-product-input.json"]) {
git("checkout", "--quiet", "--detach", "v1.0.0");
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), "Changed product input");
git("add", "--", path);
git("commit", "-m", "fix: update product input");
expect(plan(), path).toMatchObject({ version: "v1.0.1", publish: true });
}
git("checkout", "--quiet", "--detach", "v1.0.0");
mkdirSync(join(root, "site"), { recursive: true });
git("mv", "public/asset.svg", "site/asset.svg");
git("commit", "-m", "fix: retire the product asset");
expect(plan()).toMatchObject({ version: "v1.0.1", publish: true });
git("tag", "v1.0.1");
mkdirSync(join(root, "public"), { recursive: true });
git("mv", "site/asset.svg", "public/asset.svg");
git("commit", "-m", "feat: add the product asset");
expect(plan()).toMatchObject({ version: "v1.1.0", publish: true });
} finally { rmSync(root, { recursive: true, force: true }); }
}, 15_000);
it("accepts only a complete same-build package set before contacting GitHub", () => {
const root = mkdtempSync(join(tmpdir(), "piik-release-assets-"));
const version = "v1.0.0";
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { websiteDownloads } from "../site/downloads";
const revision = "a".repeat(40);
const version = "v1.2.0";
const github = {
tag_name: version, target_commitish: revision, draft: false, prerelease: false,
html_url: `https://github.com/TNTcraftHIM/Piik/releases/tag/${version}`,
assets: ["windows-amd64", "darwin-arm64", "linux-amd64"].map(target => {
const name = `piik-app-${target}-${revision.slice(0, 7)}.tar.gz`;
return { name, state: "uploaded", size: 1024,
browser_download_url: `https://github.com/TNTcraftHIM/Piik/releases/download/${version}/${name}` };
}),
};
const mirror = {
tag_name: version, prerelease: false, body: `<!-- piik-source: ${revision} -->`,
assets: github.assets.map(asset => ({ ...asset,
browser_download_url: asset.browser_download_url.replace("github.com", "gitee.com") })),
};
describe("website package downloads", () => {
it("links each uploaded platform package and rejects an incomplete or mismatched GitHub release", () => {
const result = websiteDownloads({ github, mirror });
expect(result.version).toBe(version);
expect(result.notes).toBe(github.html_url);
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset => asset.browser_download_url));
expect(result.packages.map(item => item.mirrorURL)).toEqual(mirror.assets.map(asset => asset.browser_download_url));
for (const changed of [
{ target_commitish: "main" }, { prerelease: true }, { draft: true },
{ assets: github.assets.slice(1) }, { assets: [...github.assets, github.assets[0]] },
...[
{ state: "new" }, { size: 0 },
{ name: github.assets[0]!.name.replace("aaaaaaa", "bbbbbbb") },
{ browser_download_url: github.assets[0]!.browser_download_url.replace("github.com", "evil.example") },
{ browser_download_url: github.assets[0]!.browser_download_url.replace(version, "v1.1.0") },
].map(change => ({ assets: [{ ...github.assets[0], ...change }, ...github.assets.slice(1)] })),
]) expect(() => websiteDownloads({ github: { ...github, ...changed } })).toThrow();
});
it("keeps mirror navigation as the fallback until the same release and attachment are confirmed", () => {
for (const changed of [undefined, null, { ...mirror, prerelease: true },
{ ...mirror, body: `<!-- piik-source: ${"b".repeat(40)} -->` },
{ ...mirror, tag_name: "v1.1.0" }, { ...mirror, assets: [] }]) {
expect(websiteDownloads({ github, mirror: changed }).packages.every(item => item.mirrorURL === null)).toBe(true);
}
for (const change of [{ size: 2048 }, { browser_download_url: "https://evil.example/package.tar.gz" }]) {
const result = websiteDownloads({ github,
mirror: { ...mirror, assets: [{ ...mirror.assets[0], ...change }, ...mirror.assets.slice(1)] } });
expect(result.packages[0]!.mirrorURL).toBeNull();
expect(result.packages[1]!.mirrorURL).toBe(mirror.assets[1]!.browser_download_url);
}
});
});