From 7ce974f3d451f1ffaa31b4853efc4d431bfbed30 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 30 Oct 2026 17:48:57 +0800 Subject: [PATCH] [build] integrate scoped CLI publishing with GitHub Actions --- .github/workflows/build-cli.yml | 3 + .github/workflows/build.yml | 1 + .github/workflows/release-cli.yml | 72 +++++++++ apps/cli/README.md | 6 +- apps/cli/docs/npm-distribution.md | 78 ++++++++-- apps/cli/npm/cli.cjs | 4 +- apps/cli/package.json | 2 +- apps/cli/scripts/pack-npm.cjs | 6 +- apps/cli/scripts/release-npm.cjs | 242 ++++++++++++++++++++++++++++++ 9 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/release-cli.yml create mode 100644 apps/cli/scripts/release-npm.cjs diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 14913d9a..1898ed70 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4fe35564..99d0e60b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,7 @@ on: - "*" - "!ios-v*" - "!android-v*" + - "!cli-v*" workflow_dispatch: inputs: desktop_version: diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..cf417edd --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -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" diff --git a/apps/cli/README.md b/apps/cli/README.md index 503e758d..d9e904c6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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: diff --git a/apps/cli/docs/npm-distribution.md b/apps/cli/docs/npm-distribution.md index 2512a80a..a1b7a876 100644 --- a/apps/cli/docs/npm-distribution.md +++ b/apps/cli/docs/npm-distribution.md @@ -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)。 diff --git a/apps/cli/npm/cli.cjs b/apps/cli/npm/cli.cjs index 47ce17b2..2c6c087b 100644 --- a/apps/cli/npm/cli.cjs +++ b/apps/cli/npm/cli.cjs @@ -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.`); diff --git a/apps/cli/package.json b/apps/cli/package.json index 16a6f83b..8182ef30 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/scripts/pack-npm.cjs b/apps/cli/scripts/pack-npm.cjs index fd5cc3ab..2d10d7fc 100644 --- a/apps/cli/scripts/pack-npm.cjs +++ b/apps/cli/scripts/pack-npm.cjs @@ -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 }; } diff --git a/apps/cli/scripts/release-npm.cjs b/apps/cli/scripts/release-npm.cjs new file mode 100644 index 00000000..9cf7c622 --- /dev/null +++ b/apps/cli/scripts/release-npm.cjs @@ -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 };