mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-01 19:32:38 +08:00
[build] integrate scoped CLI publishing with GitHub Actions
This commit is contained in:
@@ -2,8 +2,11 @@ name: CLI native builds and npm packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags-ignore: ["cli-v*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- "*"
|
||||
- "!ios-v*"
|
||||
- "!android-v*"
|
||||
- "!cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
desktop_version:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Publish CLI to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["cli-v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Build and validate all packages without publishing (disable only on a cli-v* tag)"
|
||||
type: boolean
|
||||
default: true
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
dry_run: ${{ steps.release.outputs.dry_run }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Validate release version and Git tag
|
||||
id: release
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
run: node apps/cli/scripts/release-npm.cjs plan
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/build-cli.yml
|
||||
|
||||
publish:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
environment: npm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
# Serialize registry writes across versions; never cancel an in-flight publish.
|
||||
concurrency:
|
||||
group: npm-cli-publish
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
RELEASE_DRY_RUN: ${{ needs.prepare.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
- name: Install npm with Trusted Publishing support
|
||||
run: npm install --global npm@12.2.1 --ignore-scripts --no-audit --no-fund
|
||||
# Keep each platform in its own directory: all five artifacts contain an
|
||||
# entry tarball, and Windows checkouts may have different line endings.
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: ${{ runner.temp }}/cli-release
|
||||
merge-multiple: false
|
||||
- name: Validate and publish six packages
|
||||
env:
|
||||
RELEASE_ARTIFACTS: ${{ runner.temp }}/cli-release
|
||||
run: node apps/cli/scripts/release-npm.cjs publish "$RELEASE_ARTIFACTS"
|
||||
+3
-3
@@ -5,11 +5,11 @@ CrossDesk 的终端主控客户端,源码位于本仓库的 `apps/cli`。通
|
||||
分发目标为 macOS / Linux(x64、arm64)和 Windows(x64)的原生终端客户端。npm 包发布后可统一使用:
|
||||
|
||||
```sh
|
||||
npx crossdesk-cli@latest doctor --json
|
||||
npx crossdesk-cli@latest 123456789
|
||||
npx @chunkdash/crossdesk-cli@latest doctor --json
|
||||
npx @chunkdash/crossdesk-cli@latest 123456789
|
||||
```
|
||||
|
||||
统一入口为 `crossdesk-cli`,五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择。旧的无 scope 平台包不属于这套依赖;仅安装平台包不会注册 `crossdesk-cli` 命令。是否可安装以 npm 上完整版本的发布结果为准,三端实机验收尚未全部完成。运行要求、打包、本地安装与发布步骤见 [npm 分发](docs/npm-distribution.md)。
|
||||
统一入口包为 `@chunkdash/crossdesk-cli`,安装后命令仍为 `crossdesk-cli`。五个平台依赖使用 `@chunkdash/crossdesk-cli-*`,由启动器自动选择。旧的无 scope 平台包不属于这套依赖;仅安装平台包不会注册命令。是否可安装以 npm 上完整版本的发布结果为准,三端实机验收尚未全部完成。运行要求、打包、本地安装与发布步骤见 [npm 分发](docs/npm-distribution.md)。
|
||||
|
||||
在 CrossDesk 仓库根目录执行以下命令构建 CLI:
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
完整版本发布后的统一入口如下。构建产物或部分平台包上传成功,不代表入口包和全部平台依赖已经可用;发布者须完成本文的注册表检查。
|
||||
|
||||
```sh
|
||||
npx crossdesk-cli@latest doctor --json
|
||||
npx crossdesk-cli@latest 123456789
|
||||
npm install -g crossdesk-cli
|
||||
npx @chunkdash/crossdesk-cli@latest doctor --json
|
||||
npx @chunkdash/crossdesk-cli@latest 123456789
|
||||
npm install -g @chunkdash/crossdesk-cli
|
||||
crossdesk-cli 123456789
|
||||
```
|
||||
|
||||
需要 Node.js 22 或以上和 npm。启动器根据 Node 的 `process.platform` / `process.arch` 选择同版本的 `@chunkdash` 平台包,直接启动原生 C++ 程序,继承终端、工作目录、环境变量和命令参数。安装过程不下载外部可执行文件、不执行 postinstall、不在用户机器编译。不要使用 `--omit=optional`,否则对应平台包不会安装。
|
||||
|
||||
入口包名为 `@chunkdash/crossdesk-cli`,`bin` 声明的终端命令仍为 `crossdesk-cli`。如已全局安装旧的无 scope 入口,先执行 `npm uninstall -g crossdesk-cli`,再安装 scoped 入口,避免两个包争用同一个命令。原来的 `crossdesk-cli@0.1.0` 是独立的旧包,改名不会更新它或自动迁移已有安装。
|
||||
|
||||
平台包仅提供原生二进制,不声明 `bin` 命令。`npm install @chunkdash/crossdesk-cli-linux-x64` 不会让 shell 出现 `crossdesk-cli`;普通用户应安装上面的通用入口。入口包的本地安装将命令放在 `node_modules/.bin`,全局安装才会放入 npm 的全局命令目录。
|
||||
|
||||
## 平台与终端
|
||||
@@ -55,7 +57,7 @@ npm --prefix apps/cli run pack:npm
|
||||
npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --out-dir /absolute/path/output
|
||||
```
|
||||
|
||||
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。
|
||||
每台构建机生成当前平台的原生包与相同版本的通用启动包。以 macOS arm64、版本 0.1.0 为例,文件名为 `chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz` 和 `chunkdash-crossdesk-cli-0.1.0.tgz`;npm 将 `@scope/name` 的 tarball 文件名写作 `scope-name-version.tgz`。五个 CI artifact 各含一份入口包,最终需要五个平台包和一份入口包,共六个 tarball。
|
||||
|
||||
`apps/cli/npm/platforms.json` 集中定义五个平台;脚本校验平台包名合法且不重复,启动包通过精确版本的 `optionalDependencies` 让 npm 仅安装匹配系统和 CPU 的原生包。生成的六个包都包含作者、源码仓库、主页、问题反馈地址,以及面向 npm 官方注册表的公开发布配置。原生包包含二进制、运行要求、SHA-256,以及当前控制端和已应用修补的 MiniRTC 源码快照。外部 Xmake 依赖的许可证、对应源码和重链接材料需要随正式版本一并提供,参见 [源码来源](source-provenance.md)。源码快照已含 MiniRTC,解包后无需运行 `prepare-deps.sh`;在 `source/` 目录按上述构建说明显式关闭桌面、开启 CLI,再构建 `crossdesk-cli`。源码包仅包含 CLI 及其依赖,不包含桌面或移动应用。
|
||||
|
||||
@@ -68,7 +70,7 @@ npm --prefix apps/cli run pack:npm -- --binary /absolute/path/crossdesk-cli --ou
|
||||
```sh
|
||||
npx --yes --offline \
|
||||
--package /absolute/path/chunkdash-crossdesk-cli-darwin-arm64-0.1.0.tgz \
|
||||
--package /absolute/path/crossdesk-cli-0.1.0.tgz \
|
||||
--package /absolute/path/chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
-- crossdesk-cli doctor --json
|
||||
```
|
||||
|
||||
@@ -78,12 +80,66 @@ Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描
|
||||
|
||||
## CI 与发布
|
||||
|
||||
`.github/workflows/build-cli.yml` 配置五个平台的原生 release 构建、doctor 和打包,并上传 tarball;该工作流不执行 npm publish。更新 MiniRTC 引用时,必须先将对应提交推送到子模块远端,再推送 CrossDesk,否则全新检出和 CI 会在拉取子模块时失败。
|
||||
`.github/workflows/build-cli.yml` 配置五个平台的原生 release 构建、doctor 和打包,并上传 tarball。普通 push、PR 和手动构建只生成 artifact;`.github/workflows/release-cli.yml` 复用同一构建流程,再汇总并发布六个 npm 包。`cli-v*` 标签只进入 CLI 发布工作流,不重复触发独立 CLI 构建或桌面发布。更新 MiniRTC 引用时,必须先将对应提交推送到子模块远端,再推送 CrossDesk,否则全新检出和 CI 会在拉取子模块时失败。
|
||||
|
||||
### GitHub Actions 自动发布
|
||||
|
||||
| 操作 | 结果 |
|
||||
| --- | --- |
|
||||
| 推送普通提交 / 提交 PR | 构建五个平台、上传 artifact,不发布 npm |
|
||||
| Actions → Publish CLI to npm → Run workflow,保持 `dry_run` 勾选 | 完整构建并执行六个包的发布预演,不写入 npm |
|
||||
| 推送 `cli-v0.1.1` | 校验源码版本为 `0.1.1`,构建并发布到 `latest` |
|
||||
| 推送 `cli-v0.2.0-rc.1` | 校验源码版本为 `0.2.0-rc.1`,构建并发布到 `next` |
|
||||
| 在对应 `cli-v*` 标签上手动运行并取消 `dry_run` | 执行真实发布;分支上不允许关闭预演 |
|
||||
|
||||
准备下一版时同步修改 `apps/cli/package.json` 的 `version` 与 `apps/cli/xmake.lua` 的 `set_version`,提交并推送代码后创建同版本标签。例如源码已更新为 `0.1.1` 时:
|
||||
|
||||
```sh
|
||||
git tag cli-v0.1.1
|
||||
git push origin cli-v0.1.1
|
||||
```
|
||||
|
||||
已经上传过 `0.1.0` 的包不能被新构建覆盖;下一次原生程序发布应使用新版本。手动预演使用 `npm pack --dry-run` 加上内容校验,不调用 `npm publish` 或交换 OIDC 凭据;可对分支运行,也允许检查已发布的版本,但不验证 npm 发布权限。标签运行始终校验版本一致性。GitHub 的手动运行入口需要该工作流先进入默认分支。
|
||||
|
||||
发布 job 只下载当前运行的五个 `npm-*` artifact,并保留独立目录。`release-npm.cjs` 校验包名、版本、平台、入口依赖、原生程序 SHA-256 和 tarball SHA-512;五份入口包的文件内容必须一致(允许 Windows CRLF 差异),实际采用 Linux x64 构建生成的入口,保留 Unix 启动权限。真实上传前先检查所有包的注册表状态,同版本内容不同或目标 dist-tag 已指向更新版本就停止。
|
||||
|
||||
上传顺序为五个平台包 → 等待其精确版本、完整性及 dist-tag 可查询 → 入口包 → 最终确认。npm 返回异步处理时,每 15 秒查询一次,每批最多等待 15 分钟;不会因查询不到版本就重复上传。真实发布带 provenance,使用 OIDC 短期凭据,只有发布 job 获得 `id-token: write`,整个流程不需要 `NPM_TOKEN`。发布 job 串行执行,不中断正在上传的版本。
|
||||
|
||||
如果发布 job 失败,优先使用 GitHub 的 **Re-run failed jobs**,复用本次已成功构建的 artifact。已存在且完整性、目标 dist-tag 一致的包会跳过;同版本不同内容、仍为 `0.0.0-stage` 的占位包、403 或网络错误都会停止并给出原因。等待 npm 处理完成或修复权限后再重跑,不要直接重跑全部构建;重新编译可能产生不同字节。如果已有版本的 dist-tag 与本次目标不一致,需要先人工检查并调整标签。本工作流不自动修改已有版本的 dist-tag,也不提供 `next` 到 `latest` 的推广操作。
|
||||
|
||||
### npm 账号一次性绑定
|
||||
|
||||
在下面六个包各自的 npm **Settings → Trusted Publisher** 中选择 **GitHub Actions**,不是在 npm 账号菜单中授权 GitHub:
|
||||
|
||||
- `@chunkdash/crossdesk-cli`
|
||||
- `@chunkdash/crossdesk-cli-darwin-arm64`
|
||||
- `@chunkdash/crossdesk-cli-darwin-x64`
|
||||
- `@chunkdash/crossdesk-cli-linux-arm64`
|
||||
- `@chunkdash/crossdesk-cli-linux-x64`
|
||||
- `@chunkdash/crossdesk-cli-windows-x64`
|
||||
|
||||
六个包使用同一组配置:
|
||||
|
||||
| npm 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Organization or user | `kunkundi`(GitHub 仓库所有者,不是 npm scope) |
|
||||
| Repository | `crossdesk` |
|
||||
| Workflow filename | `release-cli.yml`(只填写文件名) |
|
||||
| Environment name | `npm` |
|
||||
| Allowed actions | 勾选 **Allow npm publish**;本流程不需要 **Allow npm dist-tag** |
|
||||
|
||||
工作流使用 GitHub-hosted runner、Node.js 24 和 npm 12.2.1。GitHub 仓库的 `npm` environment 名称必须与 npm 配置完全一致,可在仓库 Settings → Environments 中限定只有 `cli-v*` 标签能部署;如设置此限制,分支预演也会被该环境规则拦截,应选择对应标签预演。可按团队需要设置 required reviewers;这是 GitHub 的发布审批,与 npm 2FA 不同。
|
||||
|
||||
新建 Trusted Publisher 后应在 **2 天内完成首次真实发布**,否则配置过期,需要删除后重建;预演不算成功发布。因此建议先推送工作流并完成预演,再创建 npm 信任配置,随后发布一个新版本。设置时 npm 可能要求当前账号完成浏览器 / 2FA 认证,不能由 GitHub Secrets 替代。若 scoped 入口包还没创建,需要先按下文从本地发布一次,再配置它的 Trusted Publisher。该绑定只解决身份认证,不豁免 npm 的包名与反垃圾审核。
|
||||
|
||||
### 构建缓存
|
||||
|
||||
CLI 构建分别缓存 Xmake 工具和已安装的原生依赖。原生依赖使用 `XMAKE_PKG_INSTALLDIR` 指定的 runner 临时目录,不缓存工程的 `.xmake` 配置或 `build` 产物。缓存 key 包含 runner 类型、目标架构、镜像版本、Xmake 版本以及构建配置、依赖配方和补丁的内容;不包含运行编号,也不因 CLI C++ 源码、README 或 npm 元数据修改而失效。镜像更新可能包含编译器/SDK 更新,因此会重新生成缓存;依赖配置变化时只接受新的完整 key,不回退使用旧配方的库。
|
||||
|
||||
`Restore native dependencies` 会显示缓存是否命中。各平台的 `xmake f` 成功安装依赖后立即执行 `Save native dependencies`,后续 CLI 编译、签名或打包失败也能在下一次复用;依赖安装失败时不会保存不完整缓存。Linux 仅对镜像中缺失的系统工具执行 APT 更新和安装。首次运行、缓存被清理、镜像或依赖配置变化仍需下载/编译依赖;缓存命中不代表跳过 CLI 自身构建和打包。
|
||||
|
||||
### 本地首次发布与故障恢复
|
||||
|
||||
首次使用 `@chunkdash` 前,确认当前 npm 账号属于该组织且有目标包的发布权限。本地发布需要已启用的 2FA 或符合 npm 要求的发布凭据;组织 owner 身份不代替发布认证。
|
||||
|
||||
```sh
|
||||
@@ -93,6 +149,8 @@ npm org ls chunkdash --json --registry=https://registry.npmjs.org/
|
||||
|
||||
先重新构建五个平台的包;旧的 `crossdesk-cli-<平台>` tarball 不会自动变成 scoped 包,仅重命名文件也不会改变包内名称。已经发布的旧平台包保持原样,新入口只依赖 `@chunkdash` 包。每次版本发布同步更新 `apps/cli/package.json` 和 `apps/cli/xmake.lua`;若某个目标包的版本已经发布,不能用新的内容覆盖该版本。
|
||||
|
||||
如果五个 `@chunkdash/crossdesk-cli-*` 平台包的 `0.1.0` 已经发布,此次仅将入口从 `crossdesk-cli` 迁移到 `@chunkdash/crossdesk-cli`,可以沿用新入口的 `0.1.0` 并依赖原来的五个平台版本。确认注册表中各平台版本的完整性后,只发布新的 `chunkdash-crossdesk-cli-0.1.0.tgz`;不要重发已有平台版本。后续原生程序更新仍需整套提升版本并重新构建。
|
||||
|
||||
下载并解出同一提交、同一版本的六个 tarball,在其目录内执行发布。以下以 0.1.0 为例,先检查打包内容,再逐个发布五个平台包到 `next`:
|
||||
|
||||
```sh
|
||||
@@ -102,7 +160,7 @@ npm org ls chunkdash --json --registry=https://registry.npmjs.org/
|
||||
npm publish "./chunkdash-crossdesk-cli-${target}-0.1.0.tgz" \
|
||||
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
done
|
||||
npm publish ./crossdesk-cli-0.1.0.tgz \
|
||||
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
--dry-run --ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
)
|
||||
```
|
||||
@@ -130,13 +188,11 @@ npm org ls chunkdash --json --registry=https://registry.npmjs.org/
|
||||
npm view "@chunkdash/crossdesk-cli-${target}@0.1.0" version dist.integrity \
|
||||
--registry=https://registry.npmjs.org/
|
||||
done
|
||||
npm publish ./crossdesk-cli-0.1.0.tgz \
|
||||
npm publish ./chunkdash-crossdesk-cli-0.1.0.tgz \
|
||||
--ignore-scripts --access public --tag next --registry=https://registry.npmjs.org/
|
||||
)
|
||||
```
|
||||
|
||||
再确认 `crossdesk-cli@0.1.0` 的依赖和完整性,并在五个平台用 `npx crossdesk-cli@0.1.0 doctor --json` 做安装验证;交互连接仍需实机验收。验收后按需将六个包的 `latest` 指向该版本。不要只凭 `--tag next` 假定首次发布的包不会出现 `latest`,应查询实际 `dist-tags`。缺失平台包时启动器明确报错,不下载别的平台程序或回退为源码编译。
|
||||
|
||||
后续接入 GitHub Actions Trusted Publishing 时,需要在入口包和五个 scoped 平台包各自的 npm Settings 中配置同一发布工作流;它解决发布身份认证,不豁免包名审核。当前构建工作流仍只生成 artifact,不会因为采用 scope 而自动发布。
|
||||
再确认 `@chunkdash/crossdesk-cli@0.1.0` 的依赖和完整性,并在五个平台用 `npx @chunkdash/crossdesk-cli@0.1.0 doctor --json` 做安装验证;交互连接仍需实机验收。验收后按需将六个包的 `latest` 指向该版本。不要只凭 `--tag next` 假定首次发布的包不会出现 `latest`,应查询实际 `dist-tags`。缺失平台包时启动器明确报错,不下载别的平台程序或回退为源码编译。
|
||||
|
||||
依据:[npm bin / optionalDependencies / os / cpu](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/)、[npm scopes](https://docs.npmjs.com/about-scopes/)、[npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/)、[Trusted Publishing](https://docs.npmjs.com/trusted-publishers/)、[npx](https://docs.npmjs.com/cli/v11/commands/npx/)、[GitHub runner 平台](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)、[Windows Console 模式](https://learn.microsoft.com/en-us/windows/console/getconsolemode)。
|
||||
|
||||
@@ -5,7 +5,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const platforms = require('./platforms.json');
|
||||
const { version } = require('../package.json');
|
||||
const { name: launcherPackage, version } = require('../package.json');
|
||||
|
||||
function main() {
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
@@ -21,7 +21,7 @@ function main() {
|
||||
try {
|
||||
manifestPath = require.resolve(`${packageName}/package.json`);
|
||||
} catch {
|
||||
throw new Error(`Missing ${packageName}@${version}. Install with optional dependencies enabled; this platform's package must be published before crossdesk-cli.`);
|
||||
throw new Error(`Missing ${packageName}@${version}. Install with optional dependencies enabled; this platform's package must be published before ${launcherPackage}.`);
|
||||
}
|
||||
if (require(manifestPath).version !== version) {
|
||||
throw new Error(`Expected ${packageName}@${version}; reinstall matching package versions.`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "crossdesk-cli",
|
||||
"name": "@chunkdash/crossdesk-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CrossDesk remote desktop controller for the terminal",
|
||||
|
||||
@@ -96,7 +96,11 @@ function pack(directory, output) {
|
||||
const result = process.env.npm_execpath
|
||||
? run(process.execPath, [process.env.npm_execpath, ...args], { cwd: directory })
|
||||
: run('npm', args, { cwd: directory });
|
||||
const [{ filename, size }] = JSON.parse(result);
|
||||
// npm 12 uses a name-keyed object instead of the earlier array format.
|
||||
const parsed = JSON.parse(result);
|
||||
const entries = Array.isArray(parsed) ? parsed : Object.values(parsed);
|
||||
if (entries.length !== 1) throw new Error('npm pack must produce exactly one package.');
|
||||
const [{ filename, size }] = entries;
|
||||
if (filename !== expected) throw new Error(`Unexpected npm tarball name: ${filename}; expected ${expected}`);
|
||||
return { filename, size };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { setTimeout: sleep } = require('node:timers/promises');
|
||||
const project = require('../package.json');
|
||||
const platforms = require('../npm/platforms.json');
|
||||
const registry = 'https://registry.npmjs.org/';
|
||||
const repository = 'kunkundi/crossdesk';
|
||||
|
||||
function versionParts(version) {
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$/.exec(version);
|
||||
assert(match && !(match[4] || '').split('.').some((part) => /^0\d+$/.test(part)),
|
||||
`Invalid release version: ${version}`);
|
||||
return [match.slice(1, 4).map(BigInt), match[4]?.split('.')];
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const [a, preA] = versionParts(left);
|
||||
const [b, preB] = versionParts(right);
|
||||
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
|
||||
if (!preA || !preB) return preA ? -1 : preB ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
|
||||
if (preA[i] === preB[i]) continue;
|
||||
if (preA[i] === undefined || preB[i] === undefined) return preA[i] === undefined ? -1 : 1;
|
||||
const numericA = /^\d+$/.test(preA[i]);
|
||||
const numericB = /^\d+$/.test(preB[i]);
|
||||
if (numericA !== numericB) return numericA ? -1 : 1;
|
||||
return (numericA ? BigInt(preA[i]) > BigInt(preB[i]) : preA[i] > preB[i]) ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function releasePlan() {
|
||||
assert(['true', 'false'].includes(process.env.RELEASE_DRY_RUN), 'Set RELEASE_DRY_RUN=true or false explicitly.');
|
||||
const dryRun = process.env.RELEASE_DRY_RUN === 'true';
|
||||
const version = project.version;
|
||||
versionParts(version);
|
||||
assert.equal(project.name, '@chunkdash/crossdesk-cli', 'Unexpected entry package scope.');
|
||||
const nativeVersion = fs.readFileSync(path.join(__dirname, '../xmake.lua'), 'utf8')
|
||||
.match(/set_version\("([^"]+)"\)/)?.[1];
|
||||
assert.equal(version, nativeVersion, 'package.json and xmake.lua versions must match.');
|
||||
const ref = process.env.GITHUB_REF || '';
|
||||
if (!dryRun) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Real publishing must run in GitHub Actions.');
|
||||
assert.equal(process.env.GITHUB_REPOSITORY, repository, 'Unexpected publishing repository.');
|
||||
assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Unsupported publishing event.');
|
||||
assert.equal(ref, `refs/tags/cli-v${version}`, 'Publish only from the matching cli-vVERSION tag.');
|
||||
} else if (ref.startsWith('refs/tags/')) {
|
||||
assert.equal(ref, `refs/tags/cli-v${version}`, 'Git tag and package version must match, including in a dry run.');
|
||||
}
|
||||
return { version, tag: version.includes('-') ? 'next' : 'latest', dryRun };
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...options });
|
||||
}
|
||||
|
||||
function npm(args, options) {
|
||||
return process.env.npm_execpath
|
||||
? run(process.execPath, [process.env.npm_execpath, ...args], options)
|
||||
: run('npm', args, options);
|
||||
}
|
||||
|
||||
function archiveRead(file, member, encoding = 'utf8') {
|
||||
return run('tar', ['-xOf', file, `package/${member}`], { encoding });
|
||||
}
|
||||
|
||||
function filename(name, version) {
|
||||
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
|
||||
}
|
||||
|
||||
function inspect(file, name, version) {
|
||||
const manifest = JSON.parse(archiveRead(file, 'package.json'));
|
||||
assert.equal(manifest.name, name, `Unexpected package in ${file}`);
|
||||
assert.equal(manifest.version, version, `Unexpected version in ${file}`);
|
||||
assert(!manifest.private && !manifest.scripts, `${name}: publish only generated packages without lifecycle scripts.`);
|
||||
assert.equal(manifest.repository?.url, project.repository.url, `${name}: repository mismatch.`);
|
||||
assert.deepEqual(manifest.publishConfig, { access: 'public', registry }, `${name}: publishConfig mismatch.`);
|
||||
const result = JSON.parse(npm(['pack', file, '--dry-run', '--json', '--ignore-scripts']));
|
||||
// npm 12 returns a name-keyed object; npm 11 and earlier return an array.
|
||||
const entries = Array.isArray(result) ? result : Object.values(result);
|
||||
assert.equal(entries.length, 1, `Expected one package in ${file}`);
|
||||
const [packed] = entries;
|
||||
const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`;
|
||||
assert.equal(packed.integrity, integrity, `${name}: tarball integrity mismatch.`);
|
||||
return { file, name, version, integrity, manifest, files: packed.files };
|
||||
}
|
||||
|
||||
function collectPackages(directory, version) {
|
||||
const targets = Object.keys(platforms);
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), targets.map((target) => `npm-${target}`).sort(),
|
||||
'Expected exactly five npm platform artifact directories from the same workflow run.');
|
||||
const natives = [];
|
||||
const launchers = [];
|
||||
const dependencies = Object.fromEntries(Object.values(platforms).map(({ packageName }) => [packageName, version]));
|
||||
for (const [target, platform] of Object.entries(platforms)) {
|
||||
const folder = path.join(directory, `npm-${target}`);
|
||||
const names = [filename(platform.packageName, version), filename(project.name, version)];
|
||||
assert.deepEqual(fs.readdirSync(folder).sort(), [...names].sort(), `${target}: expected native and entry tarballs.`);
|
||||
const native = inspect(path.join(folder, names[0]), platform.packageName, version);
|
||||
assert.deepEqual(native.manifest.os, [platform.os]);
|
||||
assert.deepEqual(native.manifest.cpu, [platform.cpu]);
|
||||
assert.deepEqual(native.manifest.libc, platform.libc ? [platform.libc] : undefined);
|
||||
assert(!native.manifest.bin, `${target}: native packages must not register commands.`);
|
||||
const binary = `bin/crossdesk-cli${platform.os === 'win32' ? '.exe' : ''}`;
|
||||
assert(native.files.some((file) => file.path === binary && (platform.os === 'win32' || (file.mode & 0o111))),
|
||||
`${target}: missing executable binary.`);
|
||||
const info = JSON.parse(archiveRead(native.file, 'build-info.json'));
|
||||
assert.equal(info.version, version);
|
||||
assert.equal(info.target, target);
|
||||
assert.equal(info.sha256, crypto.createHash('sha256').update(archiveRead(native.file, binary, null)).digest('hex'),
|
||||
`${target}: native binary checksum mismatch.`);
|
||||
natives.push(native);
|
||||
|
||||
const launcher = inspect(path.join(folder, names[1]), project.name, version);
|
||||
assert.deepEqual(launcher.manifest.optionalDependencies, dependencies, `${target}: platform dependencies mismatch.`);
|
||||
assert.deepEqual(launcher.manifest.bin, { 'crossdesk-cli': 'npm/cli.cjs' });
|
||||
assert.deepEqual(JSON.parse(archiveRead(launcher.file, 'npm/platforms.json')), platforms);
|
||||
launchers.push({ target, ...launcher });
|
||||
}
|
||||
// Pick a Unix entry so the shebang and executable mode work on Unix as well.
|
||||
const entry = launchers.find(({ target }) => target === 'linux-x64');
|
||||
assert(entry.files.some((file) => file.path === 'npm/cli.cjs' && (file.mode & 0o111)), 'Entry launcher is not executable.');
|
||||
const contents = Object.fromEntries(entry.files.map(({ path: member }) =>
|
||||
[member, archiveRead(entry.file, member).replace(/\r\n/g, '\n')]));
|
||||
for (const launcher of launchers) {
|
||||
assert.deepEqual(launcher.files.map((file) => file.path).sort(), Object.keys(contents).sort(),
|
||||
`${launcher.target}: entry package contents differ.`);
|
||||
for (const [member, content] of Object.entries(contents)) {
|
||||
assert.equal(archiveRead(launcher.file, member).replace(/\r\n/g, '\n'), content,
|
||||
`${launcher.target}: entry file differs: ${member}`);
|
||||
}
|
||||
}
|
||||
return [...natives, entry];
|
||||
}
|
||||
|
||||
async function readRegistry(name) {
|
||||
const response = await fetch(`${registry}${encodeURIComponent(name)}`, {
|
||||
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
assert(response.ok, `${name}: registry returned HTTP ${response.status}; refusing to treat it as a missing package.`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function checkPublished(pkg, document) {
|
||||
const published = document?.versions?.[pkg.version];
|
||||
if (!published) return false;
|
||||
assert.equal(published.dist?.integrity, pkg.integrity,
|
||||
`${pkg.name}@${pkg.version} already exists with different content. Bump the version; do not overwrite it.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForPackages(packages, tag) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
let pending = [...packages];
|
||||
while (pending.length) {
|
||||
const remaining = [];
|
||||
for (const pkg of pending) {
|
||||
const document = await readRegistry(pkg.name);
|
||||
if (!checkPublished(pkg, document) || document['dist-tags']?.[tag] !== pkg.version) remaining.push(pkg);
|
||||
}
|
||||
if (!remaining.length) return;
|
||||
assert(Date.now() < deadline, `npm is still processing: ${remaining.map((pkg) => pkg.name).join(', ')}. Wait, then re-run the failed job with the same artifacts.`);
|
||||
console.log(`Waiting for npm versions and ${tag} tags: ${remaining.map((pkg) => pkg.name).join(', ')}`);
|
||||
pending = remaining;
|
||||
await sleep(15000);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPackages(packages, { tag, dryRun }) {
|
||||
const existing = new Set();
|
||||
// Preflight every package before making any registry changes.
|
||||
for (const pkg of packages) {
|
||||
if (dryRun) continue;
|
||||
const document = await readRegistry(pkg.name);
|
||||
const current = document?.['dist-tags']?.[tag];
|
||||
assert(!current || compareVersions(current, pkg.version) <= 0,
|
||||
`${pkg.name}: ${tag} already points to newer ${current}; refusing to roll it back.`);
|
||||
if (checkPublished(pkg, document)) {
|
||||
assert.equal(current, pkg.version, `${pkg.name}: existing version matches but ${tag} does not. Review dist-tags manually before retrying.`);
|
||||
existing.add(pkg.name);
|
||||
} else {
|
||||
const versions = Object.keys(document?.versions || {});
|
||||
assert(!(versions.length === 1 && versions[0] === '0.0.0-stage'),
|
||||
`${pkg.name}: npm still has a staging placeholder. Wait for processing before retrying; do not upload it again.`);
|
||||
}
|
||||
}
|
||||
const publish = (pkg) => {
|
||||
if (dryRun) {
|
||||
// collectPackages already ran npm pack --dry-run and inspected the archive.
|
||||
// npm publish --dry-run still exchanges OIDC credentials and rejects
|
||||
// existing versions, so it cannot be used before trust is configured.
|
||||
console.log(`Validated tarball without publishing: ${pkg.name}@${pkg.version} (${tag})`);
|
||||
return;
|
||||
}
|
||||
if (existing.has(pkg.name)) {
|
||||
console.log(`Already published with matching integrity: ${pkg.name}@${pkg.version}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Publishing: ${pkg.name}@${pkg.version} (${tag})`);
|
||||
npm(['publish', pkg.file, '--access', 'public', '--tag', tag, '--ignore-scripts',
|
||||
`--registry=${registry}`, '--fetch-retries=0', '--fetch-timeout=120000',
|
||||
'--provenance'], { stdio: 'inherit' });
|
||||
};
|
||||
const native = packages.slice(0, -1);
|
||||
for (const pkg of native) publish(pkg);
|
||||
if (!dryRun) await waitForPackages(native, tag);
|
||||
const entry = packages.at(-1);
|
||||
publish(entry);
|
||||
if (!dryRun) await waitForPackages([entry], tag);
|
||||
const report = `${dryRun ? 'Validated (no packages published)' : 'Published and verified'} CLI ${entry.version} with npm tag ${tag}.\n\n` +
|
||||
'| Package | Integrity (SHA-512) |\n| --- | --- |\n' + packages.map((pkg) => `| ${pkg.name} | ${pkg.integrity} |`).join('\n') + '\n';
|
||||
console.log(report);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, directory, ...extra] = process.argv.slice(2);
|
||||
assert((command === 'plan' && !directory) || (command === 'publish' && directory && !extra.length),
|
||||
'Usage: release-npm.cjs plan | publish ARTIFACT_DIRECTORY (set RELEASE_DRY_RUN=true or false)');
|
||||
const plan = releasePlan();
|
||||
console.log(`CLI ${plan.version}, npm tag ${plan.tag}, dry run: ${plan.dryRun}`);
|
||||
if (command === 'plan') {
|
||||
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||
`version=${plan.version}\ntag=${plan.tag}\ndry_run=${plan.dryRun}\n`);
|
||||
return;
|
||||
}
|
||||
await publishPackages(collectPackages(path.resolve(directory), plan.version), plan);
|
||||
}
|
||||
|
||||
if (require.main === module) main().catch((error) => {
|
||||
console.error(`release:npm: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
module.exports = { releasePlan, compareVersions, collectPackages, publishPackages };
|
||||
Reference in New Issue
Block a user