diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b81a7d..349d3cf 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ ## Release notes - + ## Verification / 验证 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 7373416..1c7ecc8 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 08a8ae0..c46971f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a4d9af..c425801 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/docs/design/public-introduction.md b/docs/design/public-introduction.md index a5cb3d8..7da30bc 100644 --- a/docs/design/public-introduction.md +++ b/docs/design/public-introduction.md @@ -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. diff --git a/docs/operations/github.md b/docs/operations/github.md index 64add0c..1528339 100644 --- a/docs/operations/github.md +++ b/docs/operations/github.md @@ -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 diff --git a/docs/operations/website.md b/docs/operations/website.md index 1f32a22..0752c70 100644 --- a/docs/operations/website.md +++ b/docs/operations/website.md @@ -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. diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md index 88b08f9..373548c 100644 --- a/docs/reference/versioning.md +++ b/docs/reference/versioning.md @@ -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 diff --git a/docs/status.md b/docs/status.md index 4f665a8..918bb3b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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). diff --git a/docs/todo.md b/docs/todo.md index 8f83061..7fd2744 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/docs/verification-status.md b/docs/verification-status.md index 497c040..ac62cea 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -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 diff --git a/scripts/build-website.mjs b/scripts/build-website.mjs index 52ed56a..cdea1c9 100644 --- a/scripts/build-website.mjs +++ b/scripts/build-website.mjs @@ -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>/, `
${welcomeLines[0]}
`); +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(/[\s\S]*?<\/p>/, + `
${release.version}` + + `Download for your system, then extract the archive.` + + `选择对应系统,下载后解压。` + + `Release notes版本说明
`); + homepage = homepage.replace(/]*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 `` + content + .replace(/[\s\S]*?<\/span\s*>/, `${label.en}`) + .replace(/[\s\S]*?<\/span\s*>/, `${label.zh}`) + ''; + }); +} +await writeFile(new URL("index.html", output), homepage.replace( + '', `${welcomeLines.join('')}`, +)); // 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('
', '
') .replace('', '') .replace('', (await readFile(new URL("film/ui/index.html", source), "utf8")).match(/
- GitHub · GiteeOn the release page, choose a file beginning with piik-app for your system. Read the release notes and - follow the App guide.在发布页下载以 piik-app 开头、对应系统的客户端程序包,查看版本说明,再按客户端教程开始分享。 + Choose Piik App for your system on the release page. + 在发布页选择对应系统的 Piik App 程序包。
- For x64 PCs. Choose the package marked适用于 x64 电脑。请选择文件名含 windows-amd64. 的程序包。
+ For x64 PCs.适用于 x64 电脑。
- Apple silicon; native capture needs macOS 13+. Choose the package marked适用于 Apple 芯片;原生采集需 macOS 13 及以上。请选择文件名含 darwin-arm64. 的程序包。
+ Apple silicon. Native capture needs macOS 13+.适用于 Apple 芯片。原生采集需 macOS 13 及以上。
- For x64 PCs. Choose the package marked适用于 x64 电脑。请选择文件名含 linux-amd64. 的程序包。
+ For x64 PCs.适用于 x64 电脑。
The macOS and Linux apps have not yet been tested on physical devices. If you have a device, we'd welcome your test results and feedback.macOS 和 Linux 客户端尚未经过实机测试,欢迎有设备的朋友试用并反馈问题。
diff --git a/site/main.js b/site/main.js index bfae794..80ba0e2 100644 --- a/site/main.js +++ b/site/main.js @@ -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); diff --git a/site/styles.css b/site/styles.css index e12c83c..5be1fd3 100644 --- a/site/styles.css +++ b/site/styles.css @@ -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; } diff --git a/tests/release-automation.test.ts b/tests/release-automation.test.ts index 9e9d11e..acd8592 100644 --- a/tests/release-automation.test.ts +++ b/tests/release-automation.test.ts @@ -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"; diff --git a/tests/website-downloads.test.ts b/tests/website-downloads.test.ts new file mode 100644 index 0000000..053729d --- /dev/null +++ b/tests/website-downloads.test.ts @@ -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: ``, + 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: `` }, + { ...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); + } + }); +});