diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 6a4bc45..dcb4f77 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -6,13 +6,20 @@ on: branches: - '**' workflow_call: + inputs: + ref: + type: string + required: true + publish: + type: boolean + default: false permissions: contents: read concurrency: - group: check-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref_type != 'tag' }} + group: check-${{ github.workflow }}-${{ inputs.ref || github.ref }} + cancel-in-progress: ${{ !inputs.publish }} jobs: check: @@ -29,6 +36,8 @@ jobs: steps: - name: 检出代码 uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || github.sha }} - name: 安装 JDK uses: actions/setup-java@v5 with: diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..93404d4 --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,44 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: '待发布的新版本(SemVer,不带 v,例如 0.1.0 或 0.1.0-rc.1)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + name: 准备版本并创建发布 PR + runs-on: ubuntu-24.04 + steps: + - name: 限定主分支入口 + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [ "$RELEASE_REF" != 'refs/heads/master' ]; then + echo '请从 master 手动运行 Prepare Release。' + exit 1 + fi + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: 创建或恢复发布准备 PR + id: prepare + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: bun scripts/prepare-release.ts diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..182c37f --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,31 @@ +name: Publish + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + request: + name: 确认发布入口 + runs-on: ubuntu-24.04 + steps: + - name: 仅允许从 master 发布 + env: + RELEASE_REF: ${{ github.ref }} + RELEASE_SHA: ${{ github.sha }} + run: | + if [ "$RELEASE_REF" != 'refs/heads/master' ]; then + echo '请在 master 分支手动运行 Publish 工作流。' + exit 1 + fi + echo "发布提交:$RELEASE_SHA" >> "$GITHUB_STEP_SUMMARY" + + publish: + needs: request + permissions: + contents: write + uses: ./.github/workflows/release.yaml + with: + publish: true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ba8eb31..9c982bb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,69 +1,76 @@ -name: Release +name: Release Verify on: push: - tags: - - 'v*' branches: - '**' paths: - '.github/workflows/release.yaml' - '.github/workflows/check.yaml' + - '.github/workflows/publish.yaml' + - '.github/workflows/prepare-release.yaml' - 'scripts/compile.ts' - 'scripts/checksum.ts' - 'scripts/platforms.ts' - 'scripts/release.ts' + - 'scripts/publish-release.ts' + - 'scripts/prepare-release.ts' - 'scripts/smoke.ts' - 'docs/installation.md' - 'LICENSE*' - 'tests/compile.test.ts' - 'tests/release.test.ts' + - 'tests/publish-release.test.ts' + - 'tests/prepare-release.test.ts' - 'package.json' - 'bun.lockb' - 'bump.config.ts' - '.bun-version' workflow_dispatch: + workflow_call: + inputs: + publish: + description: 验收通过后创建标签并发布 + type: boolean + default: false permissions: contents: read concurrency: - group: release-${{ github.ref }} - cancel-in-progress: ${{ github.ref_type != 'tag' }} + group: release-${{ inputs.publish && 'publish' || github.ref }} + cancel-in-progress: ${{ !inputs.publish }} jobs: metadata: name: 校验版本与发布目标 runs-on: ubuntu-24.04 outputs: - tag: ${{ steps.metadata.outputs.tag }} - prerelease: ${{ steps.metadata.outputs.prerelease }} matrix: ${{ steps.metadata.outputs.matrix }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version - - name: 校验标签并生成产物清单 + - name: 校验版本并生成产物清单 id: metadata - run: bun scripts/release.ts metadata - - name: 检查发布状态 - if: github.event_name == 'push' && github.ref_type == 'tag' + run: bun scripts/release.ts metadata ${{ inputs.publish && '--publish' || '' }} + - name: 检查发布请求、主分支与已有版本 + if: inputs.publish env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.metadata.outputs.tag }} - run: | - if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then - if [ "$state" != 'true' ]; then - echo '该标签已经公开发布,请使用新版本标签。' - exit 1 - fi - fi + run: bun scripts/publish-release.ts check checks: needs: metadata - if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + if: inputs.publish || github.event_name == 'workflow_dispatch' uses: ./.github/workflows/check.yaml + with: + ref: ${{ github.sha }} + publish: ${{ inputs.publish || false }} build: name: 验收 ${{ matrix.name }} @@ -81,6 +88,8 @@ jobs: steps: - name: 检出待验收提交 uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} - name: 安装固定版本 Bun uses: oven-sh/setup-bun@v2 with: @@ -138,6 +147,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version @@ -165,19 +176,20 @@ jobs: overwrite: true release: - name: 发布完整标签产物 + name: 创建标签并发布已验收产物 needs: [metadata, checks, artifacts] - if: github.event_name == 'push' && github.ref_type == 'tag' + if: inputs.publish runs-on: ubuntu-24.04 permissions: contents: write env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ needs.metadata.outputs.tag }} - RELEASE_PRERELEASE: ${{ needs.metadata.outputs.prerelease }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .bun-version @@ -188,34 +200,5 @@ jobs: - name: 核对待上传文件 working-directory: dist run: sha256sum --check SHA256SUMS - - name: 生成下载与安装说明 - run: | - bun scripts/release.ts notes > release-notes.md - gh api --method POST "repos/$GH_REPO/releases/generate-notes" \ - -f tag_name="$RELEASE_TAG" --jq .body >> release-notes.md - - name: 创建或恢复未公开草稿 - run: | - if state=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft); then - if [ "$state" != 'true' ]; then - echo '该标签已经公开发布,请使用新版本标签。' - exit 1 - fi - gh release edit "$RELEASE_TAG" --title "java-run $RELEASE_TAG" \ - --notes-file release-notes.md --prerelease="$RELEASE_PRERELEASE" - else - gh release create "$RELEASE_TAG" --verify-tag --draft \ - --notes-file release-notes.md --title "java-run $RELEASE_TAG" \ - --prerelease="$RELEASE_PRERELEASE" - fi - - name: 上传完整资产并核对草稿 - run: | - gh release upload "$RELEASE_TAG" dist/* --clobber - gh release view "$RELEASE_TAG" --json assets --jq '[.assets[].name]' \ - | bun scripts/release.ts verify-assets - - name: 公开已验收版本 - run: | - if [ "$RELEASE_PRERELEASE" = 'true' ]; then - gh release edit "$RELEASE_TAG" --draft=false --prerelease=true --latest=false - else - gh release edit "$RELEASE_TAG" --draft=false --prerelease=false - fi + - name: 创建标签、上传草稿资产并公开版本 + run: bun scripts/publish-release.ts publish diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0ecd3..da93c7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,17 +137,19 @@ Markdown 正文使用正常中文标点,同组列表保持语法与标点统 Linux、macOS、Windows 与 JDK 17 / 21 组成六组矩阵,分别执行锁文件安装、类型检查、回归测试、本机编译和完整 smoke。 最新结果可在 [GitHub Actions](https://github.com/Vanisper/java-run/actions/workflows/check.yaml) 查看。 -[Release 工作流](.github/workflows/release.yaml) 共用同一套构建与验收步骤,提供以下入口: +[发布准备](.github/workflows/prepare-release.yaml)、[发布验收](.github/workflows/release.yaml) +和[正式发布](.github/workflows/publish.yaml)分别提供以下入口: -| 入口 | 验收与输出 | -| --- | --- | -| 分支 push 命中 `paths` | 五平台产物演练,保存 Actions artifacts 和发布说明预览;Check 独立运行 | -| `workflow_dispatch` | 手动产物演练,并复用完整 Check | -| `v*` 标签 push | 校验版本与许可证,执行完整 Check 和五平台验收,通过后发布 GitHub Release | +| 工作流 | 入口 | 验收与输出 | +| --- | --- | --- | +| Prepare Release | 在 `master` 手动输入新版本 | 创建临时发布分支、更新版本号并提交发布 PR | +| Release Verify | 分支 push 命中 `paths` | 五平台产物演练,保存 Actions artifacts 和发布说明预览;Check 独立运行 | +| Release Verify | 手动运行 | 五平台产物演练,并复用完整 Check | +| Publish | 在 `master` 手动运行 | 校验发布条件,执行完整 Check 和五平台验收,通过后创建标签并发布 GitHub Release | `paths` 限制分支演练的触发范围,包括工作流、发布脚本、相关测试、版本配置和安装指南等,完整清单以工作流为准。 -标签 push 不受此路径条件限制。 -工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 选择分支手动演练。 +手动入口不受路径条件限制。工作流进入默认分支后,可通过 Actions 页面或 GitHub CLI 运行。 +Publish 复用 Release Verify 的构建与验收步骤;标签由发布流程创建,推送标签不会触发发布。 ### 发布平台与产物 @@ -173,7 +175,7 @@ Linux、macOS、Windows 与 JDK 17 / 21 组成六组矩阵,分别执行锁文 - `java-run` 或 `java-run.exe` - 对应的 `java-run.sha256` 或 `java-run.exe.sha256` - 从 [安装指南](docs/installation.md) 复制的 `INSTALL.md` -- 仓库中存在的 `LICENSE`、`LICENSE.md` 或 `LICENSE.txt` +- MIT 许可证 `LICENSE` 汇总步骤要求恰好包含五种非空 ZIP 普通文件,然后生成并核对 `SHA256SUMS`。 包内 `.sha256` 校验二进制,Release 单独提供的 `SHA256SUMS` 校验下载的 ZIP。 @@ -188,7 +190,7 @@ Linux 产物在 Ubuntu 24.04 的 glibc 环境验收,musl 和其他系统版本 | 子命令 | 职责 | | --- | --- | -| `metadata` | 校验版本,输出标签、预发布标识和平台矩阵;标签环境还校验标签与许可证 | +| `metadata` | 校验版本,输出标签、预发布标识和平台矩阵;加 `--publish` 时还要求存在非空许可证文件 | | `package <平台>` | 打包 `dist` 中已有的二进制与校验文件,解压核对并执行 quick | | `checksums` | 核对 `dist` 中的五份 ZIP 集合,生成 `SHA256SUMS` | | `notes` | 向标准输出生成下载与安装说明 | @@ -209,29 +211,44 @@ bun scripts/release.ts package darwin-arm64 五个平台的 ZIP 汇集到只含这些文件的 `dist` 目录后,可执行 `checksums`。 编译留下的裸二进制和 `.sha256` 不属于汇总输入。 分支与手动演练保存产物和说明预览,不创建标签或 GitHub Release。 +`metadata` 根据当前源码生成元数据,不依赖所在分支或标签。 +`scripts/publish-release.ts` 由 Publish 工作流调用,不属于本地演练步骤。 ### 发布版本 -发布前完成以下准备: - -- 确定开源许可证,添加非空的 `LICENSE`、`LICENSE.md` 或 `LICENSE.txt` -- 确定发布版本,更新 `package.json` 并核对版本输出 -- 完成待发布提交的检查,审查平台产物和支持边界 - -`bun run version` 使用 bumpp 调整 `package.json`,不自动提交、创建标签或推送。 -版本变更通过 PR 合入主分支后,创建并推送 `v` 标签。 -版本必须符合 SemVer,标签必须与包版本严格一致。 - -元数据校验通过后,同一标签提交的六组 Check 与五平台产物验收并行执行。 -全部通过后,工作流创建或恢复未公开草稿,上传五份 ZIP 和 `SHA256SUMS`。 +项目采用 [MIT 许可证](LICENSE),发布压缩包包含许可证原文。 +在仓库 **Settings → Actions → General → Workflow permissions** 中启用 +**Allow GitHub Actions to create and approve pull requests**,允许 Prepare Release 创建发布 PR。 +组织级策略也需要允许此设置。 + +每个版本按以下流程准备与发布: + +1. 将本次要发布的功能与修复合入 `master`。 +2. 在 Actions 打开 **Prepare Release**,点击 **Run workflow**,选择 `master`, + 输入不带 `v` 的新版本,如 `0.1.0` 或 `0.1.0-rc.1`。 +3. 审查自动创建的发布 PR。若页面显示 **Approve workflows to run**, + 按 [GitHub 工作流触发规则](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow)批准运行, + 等待检查通过后合并。 +4. 在 Actions 打开 **Publish**,点击 **Run workflow**,选择 `master`,执行正式发布。 +5. 发布完成后,检查 Release 的标签、下载表、ZIP 内容和校验和, + 并按安装指南核对下载后的版本输出;临时发布分支可删除。 + +Prepare Release 从本次手动运行绑定的提交创建 `release/<版本>` 分支,仅修改 `package.json` 的版本号。 +新版本必须符合 SemVer,并高于该提交中的当前版本。 +工作流保留已有分支和标签,也不会同时创建多个待合并的发布 PR。 +需要手动准备版本时,可使用 `bun run version` 调整 `package.json`,再通过 PR 合入 `master`。 +该命令不自动提交、创建标签或推送;合并后的发布入口同样是 Publish。 + +Publish 从所选提交的 `package.json` 读取版本,无需再次输入版本号。 +整次运行锁定触发时的 `master` 提交,后续主分支更新不会改变本次待发布代码。 +工作流先校验版本、许可证、提交归属及已有标签与 Release,再对同一提交并行执行六组 Check 和五平台产物验收。 +全部通过后,创建指向该提交的 `v<版本>` 附注标签,以及对应的未公开草稿,上传五份 ZIP 和 `SHA256SUMS`。 Release 正文包含下载表、安装说明及 GitHub 生成的变更说明。 公开前会核对远端资产集合,缺失或额外文件均使流程失败并保留草稿。 上传和核对成功后自动公开,无需额外人工审批。 预发布版本标记为 prerelease,且不标记为 latest;稳定版的 latest 选择使用 GitHub 默认规则。 -同一标签的运行串行执行,不中断正在进行的发布。 - -发布完成后,检查 Release 的标签、下载表、ZIP 内容和校验和,并按安装指南核对下载后的版本输出。 +正式发布运行串行执行,不中断正在进行的发布。 ### 失败重试 @@ -241,6 +258,11 @@ Release 正文包含下载表、安装说明及 GitHub 生成的变更说明。 gh run rerun --failed ``` -重跑使用原始提交和 ref,已验收的 Actions artifacts 支持同名覆盖。 -Release 资产只允许在未公开草稿中替换;已经公开的标签版本会被拒绝。 -修改代码后应运行新提交的验收,已发布版本的修正通过新版本发布。 +Prepare Release 的原始运行可以重跑,恢复同版本、同起点的分支和 PR。 +主分支前进后再次点击 Run workflow 会绑定新的起点,不会覆盖此前创建的发布分支。 +如需调整已有发布准备,直接在对应 PR 中修改并接受检查。 + +Publish 重跑使用原始提交和 ref,已验收的 Actions artifacts 支持同名覆盖。 +流程可以复用指向同一提交的已有标签,并恢复未公开草稿;指向其他提交的标签和已公开版本会被拒绝。 +Release 资产只允许在未公开草稿中替换。 +修改代码后应通过 PR 合入 `master`,再运行新提交的验收;已发布版本的修正通过新版本发布。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3db1ff9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vanisper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index cd83a9b..b86620d 100644 --- a/README.md +++ b/README.md @@ -326,3 +326,7 @@ Windows 上的 Maven 配置根超出系统代码页时,需要 Maven **3.9.2 | [技术路线](docs/roadmap.md) | 已有工程能力、支持缺口和后续工作的验收条件 | | [参与开发](CONTRIBUTING.md) | 本地开发、验证、分支协作与发布流程 | | [验收夹具](tests/fixtures/README.md) | 真实项目场景、隔离方式和验证覆盖 | + +## 许可证 + +本项目采用 [MIT 许可证](LICENSE)。 diff --git a/docs/installation.md b/docs/installation.md index 191b7cf..643c4d3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -46,7 +46,7 @@ ZIP 解压后只有一个 `java-run-<平台>/` 目录,内含: - `java-run` 或 `java-run.exe` - 对应的 `java-run.sha256` 或 `java-run.exe.sha256` - 本安装指南 `INSTALL.md` -- 许可证文件 +- MIT 许可证 `LICENSE` Release 的 `SHA256SUMS` 用于校验下载的 ZIP,包内 `.sha256` 用于校验解压后的二进制。 按下面的步骤完成两次核对,再安装到 PATH。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 0457d02..f154a97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ java-run 为 Maven 与 Gradle 的 Java 源码工作区提供统一启动入口 测试范围与夹具用途见 [夹具说明](../tests/fixtures/README.md)。 - [Check](../.github/workflows/check.yaml):Ubuntu、macOS、Windows 分别在 JDK 17 和 21 下执行源码回归、二进制编译和完整运行验收 -- [Release](../.github/workflows/release.yaml):五个平台分别构建,JDK 21 完整运行验收,JDK 17 解压产物启动验收,再汇总并校验 ZIP +- [Release Verify](../.github/workflows/release.yaml):五个平台分别构建,JDK 21 完整运行验收,JDK 17 解压产物启动验收,再汇总并校验 ZIP 固定环境的验收提供对应系统、体系结构和 JDK 的运行证据。 其他构建工具版本、第三方插件和系统环境需要代表项目补充验证;平台范围见 [安装指南](installation.md)。 diff --git a/package.json b/package.json index 5fe8bf2..b405a8b 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "module": "src/cli.ts", "type": "module", "version": "0.0.5", + "license": "MIT", "scripts": { "version": "bumpp", "start": "bun run ./src/cli.ts", diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts new file mode 100644 index 0000000..552b00f --- /dev/null +++ b/scripts/prepare-release.ts @@ -0,0 +1,177 @@ +import { spawnSync } from 'node:child_process'; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { releaseMetadata } from './release'; + +interface PullRequest { + url: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + headRefOid: string; + headRefName: string; + isCrossRepository: boolean; +} + +interface PullRequests { + list(branch: string): PullRequest[]; + create(branch: string, title: string, bodyFile: string): string; +} + +/** 已准备的发布分支、提交和对应 PR */ +export interface PreparedRelease { + branch: string; + sha: string; + pr_url: string; +} + +function command(cwd: string, program: string, args: string[], env: NodeJS.ProcessEnv = process.env): string { + const result = spawnSync(program, args, { cwd, env, encoding: 'utf8' }); + if (result.error || result.status !== 0) { + throw new Error(`${program} 执行失败:${result.error?.message ?? result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +function githubPullRequests(cwd: string, repository: string): PullRequests { + return { + list(branch) { + const opened: PullRequest[] = JSON.parse(command(cwd, 'gh', [ + 'pr', 'list', '--repo', repository, '--base', 'master', '--state', 'open', + '--json', 'url,state,headRefOid,headRefName,isCrossRepository', '--limit', '1000', + ])); + if (opened.length === 1000) throw new Error('未合并 PR 数量过多,无法确认其他发布准备,请由维护者核对'); + const matching: PullRequest[] = JSON.parse(command(cwd, 'gh', [ + 'pr', 'list', '--repo', repository, '--base', 'master', '--head', branch, + '--state', 'all', '--json', 'url,state,headRefOid,headRefName,isCrossRepository', '--limit', '100', + ])); + return [...matching, ...opened.filter(request => request.headRefName !== branch)]; + }, + create(branch, title, bodyFile) { + try { + return command(cwd, 'gh', [ + 'pr', 'create', '--repo', repository, '--base', 'master', '--head', branch, + '--title', title, '--body-file', bodyFile, + ]); + } catch (error) { + throw new Error(`无法创建发布 PR,已推送的 ${branch} 分支会保留,可修正后使用同版本重跑。\n${(error as Error).message}\n若仓库禁止 Actions 创建 PR,请由维护者在 Settings → Actions → General → Workflow permissions 启用 Allow GitHub Actions to create and approve pull requests。`); + } + }, + }; +} + +/** + * 从指定 master 提交准备版本并创建或恢复发布 PR + * + * @description 只写入新的远端发布分支;已有分支必须与本次准备的父提交、文件内容和机器人提交标识完全一致 + */ +export function prepareRelease(options: { + cwd: string; + version: string; + baseSha: string; + repository: string; + pullRequests?: PullRequests; +}): PreparedRelease { + const { cwd, version, baseSha, repository } = options; + const metadata = releaseMetadata(version); + if (!/^[0-9a-f]{40}$/.test(baseSha)) throw new Error('发布准备必须指定完整的 master 提交 SHA'); + const git = (...args: string[]) => command(cwd, 'git', args); + if (git('rev-parse', 'HEAD') !== baseSha) throw new Error('当前检出提交与发布准备提交不一致'); + if (git('status', '--porcelain')) throw new Error('工作区存在未提交改动,请先保存改动'); + git('merge-base', '--is-ancestor', baseSha, 'origin/master'); + const manifest = JSON.parse(git('show', `${baseSha}:package.json`)); + if (typeof manifest.version !== 'string') throw new Error('package.json 必须包含版本字符串'); + releaseMetadata(manifest.version); + const previousVersion = manifest.version; + if (Bun.semver.order(version, manifest.version) <= 0) { + throw new Error(`新版本 ${version} 必须高于当前版本 ${manifest.version},构建元数据不提高版本优先级`); + } + + const branch = `release/${version}`; + const branchRef = `refs/heads/${branch}`; + const title = `chore(release): prepare ${metadata.tag}`; + const message = `${title}\n\nJava-Run-Release-Base: ${baseSha}`; + git('check-ref-format', branchRef); + if (git('ls-remote', '--refs', 'origin', `refs/tags/${metadata.tag}`)) { + throw new Error(`标签 ${metadata.tag} 已存在,请使用新的发布版本`); + } + const requests = options.pullRequests ?? githubPullRequests(cwd, repository); + const releaseRequests = requests.list(branch).filter(request => !request.isCrossRepository); + if (releaseRequests.some(request => request.state === 'OPEN' + && request.headRefName.startsWith('release/') && request.headRefName !== branch)) { + throw new Error('已有其他未合并的发布准备 PR,请先合并或关闭它,再准备新版本'); + } + const existingRequests = releaseRequests.filter(request => request.headRefName === branch); + if (existingRequests.length > 1 || existingRequests.some(request => request.state !== 'OPEN')) { + throw new Error(`${branch} 已关联关闭、合并或多个 PR,请使用新版本或由维护者核对已有 PR`); + } + const existingRequest = existingRequests[0]; + const remoteSha = git('ls-remote', '--refs', 'origin', branchRef).split(/\s+/)[0] || undefined; + if (existingRequest && !remoteSha) throw new Error('已有发布 PR 的远端分支不存在,请由维护者检查'); + + const temporary = mkdtempSync(join(tmpdir(), 'java-run-prepare-release-')); + const worktree = join(temporary, 'worktree'); + let worktreeAdded = false; + try { + git('worktree', 'add', '--detach', worktree, baseSha); + worktreeAdded = true; + manifest.version = version; + writeFileSync(join(worktree, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + command(worktree, 'git', ['add', '--', 'package.json']); + const tree = command(worktree, 'git', ['write-tree']); + let sha: string; + if (remoteSha) { + git('fetch', '--no-tags', 'origin', remoteSha); + if (git('rev-parse', `${remoteSha}^{tree}`) !== tree + || git('show', '-s', '--format=%P', remoteSha) !== baseSha + || git('show', '-s', '--format=%B', remoteSha) !== message + || git('show', '-s', '--format=%ae', remoteSha) !== '41898282+github-actions[bot]@users.noreply.github.com') { + throw new Error(`${branch} 已存在且不匹配本次发布准备,拒绝覆盖已有分支`); + } + sha = remoteSha; + } else { + command(worktree, 'git', ['-c', 'core.hooksPath=', '-c', 'commit.gpgsign=false', 'commit', '-m', message], { + ...process.env, + GIT_AUTHOR_NAME: 'github-actions[bot]', + GIT_AUTHOR_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com', + GIT_COMMITTER_NAME: 'github-actions[bot]', + GIT_COMMITTER_EMAIL: '41898282+github-actions[bot]@users.noreply.github.com', + }); + sha = command(worktree, 'git', ['rev-parse', 'HEAD']); + command(worktree, 'git', ['push', 'origin', `${sha}:${branchRef}`]); + } + if (existingRequest) { + if (existingRequest.headRefOid !== sha) throw new Error('已有发布 PR 的提交与远端发布分支不一致,请稍后重试'); + return { branch, sha, pr_url: existingRequest.url }; + } + const bodyFile = join(temporary, 'pull-request.md'); + writeFileSync(bodyFile, `准备发布 \`${metadata.tag}\`,将 \`package.json\` 版本从 \`${previousVersion}\` 更新为 \`${version}\`。\n\n准备基于提交 \`${baseSha}\`,本 PR 不包含其他功能变更。\n\n由 Actions 创建的 PR 可能需要维护者点击 **Approve workflows to run** 才会开始检查。检查通过并合并到 \`master\` 后,在 Actions 中运行 **Publish** 工作流发布该版本。发布流程会验收固定提交的五平台产物,再创建标签和 GitHub Release;本 PR 不会自动合并或发布。\n`); + return { branch, sha, pr_url: requests.create(branch, title, bodyFile) }; + } finally { + if (worktreeAdded) git('worktree', 'remove', '--force', worktree); + rmSync(temporary, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + try { + if (process.env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || process.env.GITHUB_REF !== 'refs/heads/master') { + throw new Error('发布准备只能从 master 手动运行 Prepare Release 工作流'); + } + const result = prepareRelease({ + cwd: resolve(import.meta.dir, '..'), + version: process.env.RELEASE_VERSION ?? '', + baseSha: process.env.GITHUB_SHA ?? '', + repository: process.env.GITHUB_REPOSITORY ?? '', + }); + console.log(JSON.stringify(result, null, 2)); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `${Object.entries(result).map(([key, value]) => `${key}=${value}`).join('\n')}\n`); + } + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `发布准备 PR:${result.pr_url}\n\n分支:\`${result.branch}\`\n\n提交:\`${result.sha}\`\n\n合并后,从 master 手动运行 Publish 发布。\n`); + } + } catch (error) { + console.error((error as Error).message); + process.exitCode = 1; + } +} diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts new file mode 100644 index 0000000..866ac96 --- /dev/null +++ b/scripts/publish-release.ts @@ -0,0 +1,145 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { assertReleaseAssets, assertReleaseLicense, releaseMetadata, releaseNotes } from './release'; + +const projectRoot = resolve(import.meta.dir, '..'); + +function command(directory: string, program: string, args: string[], input?: string): string { + const result = spawnSync(program, args, { cwd: directory, encoding: 'utf8', input }); + if (result.error || result.status !== 0) { + throw new Error(`${program} 执行失败:${result.error?.message ?? result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +/** 正式发布只能由 master 上的手动请求启动,所有任务使用该请求锁定的提交 */ +export function assertPublishRequest(event: string | undefined, ref: string | undefined, sha: string | undefined): asserts sha is string { + if (event !== 'workflow_dispatch' || ref !== 'refs/heads/master' || !sha || !/^[0-9a-f]{40}$/.test(sha)) { + throw new Error('请在 master 分支手动运行 Publish 工作流'); + } +} + +/** 检查发布提交属于主分支,并拒绝指向其他提交的同名远端标签 */ +export function verifyReleaseSource(directory: string, tag: string, sha: string): { tagged: boolean } { + const git = (...args: string[]) => command(directory, 'git', args); + const version = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')).version; + releaseMetadata(version, tag); + if (!/^[0-9a-f]{40}$/.test(sha) || git('rev-parse', 'HEAD') !== sha) throw new Error('当前检出提交与锁定的发布提交不一致'); + git('fetch', '--no-tags', 'origin', 'refs/heads/master:refs/remotes/origin/master'); + const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', sha, 'refs/remotes/origin/master'], { cwd: directory }); + if (ancestor.error || ancestor.status !== 0) throw new Error('发布提交不属于远端 master'); + const refs = git('ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`); + const entries = new Map(refs.split('\n').filter(Boolean).map(line => { + const [object, ref] = line.split(/\s+/); + return [ref!, object!] as const; + })); + const target = entries.get(`refs/tags/${tag}^{}`) ?? entries.get(`refs/tags/${tag}`); + if (target && target !== sha) throw new Error(`远端标签 ${tag} 指向其他提交,不能覆盖`); + return { tagged: target !== undefined }; +} + +/** 创建附注标签;重试时仅复用已经指向同一提交的远端标签 */ +export function pushReleaseTag(directory: string, tag: string, sha: string): void { + if (verifyReleaseSource(directory, tag, sha).tagged) return; + // 直接创建附注对象,不复用或改写本地同名标签 + const annotation = `object ${sha}\ntype commit\ntag ${tag}\ntagger github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> ${Math.floor(Date.now() / 1000)} +0000\n\n${tag}\n`; + const object = command(directory, 'git', ['mktag'], annotation); + command(directory, 'git', ['push', 'origin', `${object}:refs/tags/${tag}`]); +} + +interface DraftRelease { + id: number; + draft: true; + assets: { name: string }[]; +} + +/** 查询未公开草稿;已发布版本、仓库不可访问和查询失败均拒绝继续 */ +export async function readReleaseDraft(repository: string, tag: string, token: string, + request: (url: string, init: RequestInit) => Promise = fetch): Promise { + const [owner, name] = repository.split('/'); + // REST 的按标签查询只返回已公开版本,GraphQL 可同时读取草稿 + const response = await request('https://api.github.com/graphql', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28' }, + body: JSON.stringify({ + query: `query($owner: String!, $name: String!, $tag: String!) { + repository(owner: $owner, name: $name) { + release(tagName: $tag) { + databaseId tagName isDraft + releaseAssets(first: 100) { nodes { name } pageInfo { hasNextPage } } + } + } + }`, + variables: { owner, name, tag }, + }), + }); + if (!response.ok) throw new Error(`查询 Release 失败:HTTP ${response.status}`); + const result = await response.json() as { + errors?: { message: string }[]; + data?: { repository: { release: { + databaseId: number; tagName: string; isDraft: boolean; + releaseAssets: { nodes: { name: string }[]; pageInfo: { hasNextPage: boolean } }; + } | null } | null }; + }; + if (result.errors?.length || !result.data?.repository) throw new Error('查询 Release 失败:GitHub 返回错误或仓库不可访问'); + const release = result.data.repository.release; + if (!release) return undefined; + if (release.tagName !== tag) throw new Error('Release 标签与请求不一致'); + if (!release.isDraft) throw new Error(`版本 ${tag} 已公开发布,请使用新版本`); + if (release.releaseAssets.pageInfo.hasNextPage) throw new Error('Release 草稿包含过多资产,请检查额外文件'); + return { id: release.databaseId, draft: true, assets: release.releaseAssets.nodes }; +} + +async function main(action: string | undefined): Promise { + const sha = process.env.GITHUB_SHA; + assertPublishRequest(process.env.GITHUB_EVENT_NAME, process.env.GITHUB_REF, sha); + if (action !== 'check' && action !== 'publish') throw new Error('用法:bun scripts/publish-release.ts check | publish'); + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GH_TOKEN; + if (!repository || !/^[\w.-]+\/[\w.-]+$/.test(repository) || !token) throw new Error('缺少 GitHub 仓库或认证令牌'); + assertReleaseLicense(projectRoot); + const version = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')).version; + const metadata = releaseMetadata(version); + const source = verifyReleaseSource(projectRoot, metadata.tag, sha); + let draft = await readReleaseDraft(repository, metadata.tag, token); + if (draft && !source.tagged) throw new Error('已有发布草稿但对应标签不存在,请检查草稿与发布提交'); + if (action === 'check') return; + + pushReleaseTag(projectRoot, metadata.tag, sha); + const gh = (...args: string[]) => command(projectRoot, 'gh', args); + const temporary = mkdtempSync(join(tmpdir(), 'java-run-release-')); + try { + const notes = join(temporary, 'notes.md'); + const changes = gh('api', '--method', 'POST', `repos/${repository}/releases/generate-notes`, + '-f', `tag_name=${metadata.tag}`, '-f', `target_commitish=${sha}`, '--jq', '.body'); + writeFileSync(notes, `${releaseNotes(version)}\n${changes}\n`); + draft = await readReleaseDraft(repository, metadata.tag, token); + if (draft) { + gh('release', 'edit', metadata.tag, '--title', `java-run ${metadata.tag}`, + '--notes-file', notes, `--prerelease=${metadata.prerelease}`); + } else { + gh('release', 'create', metadata.tag, '--verify-tag', '--draft', '--title', `java-run ${metadata.tag}`, + '--notes-file', notes, `--prerelease=${metadata.prerelease}`); + } + const beforeUpload = await readReleaseDraft(repository, metadata.tag, token); + if (!beforeUpload) throw new Error('发布草稿不存在'); + if (!verifyReleaseSource(projectRoot, metadata.tag, sha).tagged) throw new Error('发布标签已被删除'); + const files = [...metadata.matrix.include.map(platform => platform.archive), 'SHA256SUMS']; + gh('release', 'upload', metadata.tag, ...files.map(file => join('dist', file)), '--clobber'); + const ready = await readReleaseDraft(repository, metadata.tag, token); + if (!ready || ready.id !== beforeUpload.id) throw new Error('发布草稿已发生变化'); + assertReleaseAssets(ready.assets.map(asset => asset.name), version); + if (!verifyReleaseSource(projectRoot, metadata.tag, sha).tagged) throw new Error('发布标签已被删除'); + gh('release', 'edit', metadata.tag, '--draft=false', `--prerelease=${metadata.prerelease}`, + ...(metadata.prerelease ? ['--latest=false'] : [])); + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +if (import.meta.main) { + main(Bun.argv.length === 3 ? Bun.argv[2] : undefined).catch(error => { + console.error(`java-run:${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/release.ts b/scripts/release.ts index d26cc6f..c0988dc 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -10,6 +10,14 @@ const projectRoot = resolve(import.meta.dir, '..'); const licenseNames = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; const repository = 'https://github.com/Vanisper/java-run'; +/** 正式发布要求仓库包含非空许可证文件 */ +export function assertReleaseLicense(directory: string): void { + if (!licenseNames.some(file => { + try { return readFileSync(resolve(directory, file), 'utf8').trim().length > 0; } + catch { return false; } + })) throw new Error('正式发布需要先补齐 LICENSE 文件'); +} + /** 发布版本、预发布标识与需要原生验收的文件集合 */ export interface ReleaseMetadata { version: string; @@ -215,14 +223,13 @@ export async function writeReleaseChecksums(directory: string, version: string): async function main(argv: string[]): Promise { const version: unknown = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')).version; if (typeof version !== 'string') throw new Error('package.json 必须包含版本字符串'); - if (argv.length !== (argv[0] === 'package' ? 2 : 1)) throw new Error('用法:bun scripts/release.ts metadata | notes | checksums | verify-assets | package <平台>'); + const publishing = argv[0] === 'metadata' && argv[1] === '--publish'; + if (argv.length !== (argv[0] === 'package' || publishing ? 2 : 1)) { + throw new Error('用法:bun scripts/release.ts metadata [--publish] | notes | checksums | verify-assets | package <平台>'); + } if (argv[0] === 'metadata') { - const metadata = releaseMetadata(version, process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : undefined); - if (process.env.GITHUB_REF_TYPE === 'tag') { - if (!licenseNames.some(file => { - try { return readFileSync(resolve(projectRoot, file), 'utf8').trim().length > 0; } catch { return false; } - })) throw new Error('正式发布需要先补齐 LICENSE 文件'); - } + const metadata = releaseMetadata(version); + if (publishing) assertReleaseLicense(projectRoot); console.log(JSON.stringify(metadata, null, 2)); if (process.env.GITHUB_OUTPUT) { const outputs = Object.entries(metadata).map(([key, value]) => `${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`); diff --git a/tests/prepare-release.test.ts b/tests/prepare-release.test.ts new file mode 100644 index 0000000..641a4b4 --- /dev/null +++ b/tests/prepare-release.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { prepareRelease } from '../scripts/prepare-release'; + +// 实际 Git 进程集成测试需要覆盖不同平台的进程启动耗时 +const gitTestTimeout = 30_000; +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function git(cwd: string, ...args: string[]): string { + const result = spawnSync('git', ['-c', 'core.hooksPath=', '-c', 'commit.gpgsign=false', ...args], { + cwd, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + if (result.error || result.status !== 0) throw new Error(result.error?.message ?? result.stderr); + return result.stdout.trim(); +} + +function fixture(version = '0.0.5') { + const directory = mkdtempSync(join(tmpdir(), 'java-run-prepare-test-')); + directories.push(directory); + const cwd = join(directory, 'checkout'); + const origin = join(directory, 'origin.git'); + git(directory, 'init', '--bare', '--initial-branch=master', origin); + git(directory, 'init', '--initial-branch=master', cwd); + git(cwd, 'config', 'user.name', 'Release test'); + git(cwd, 'config', 'user.email', 'release-test@example.invalid'); + git(cwd, 'config', 'commit.gpgsign', 'false'); + const manifest = { name: 'java-run', version, scripts: { test: 'bun test' }, dependencies: { example: '^1.0.0' } }; + writeFileSync(join(cwd, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + writeFileSync(join(cwd, 'README.md'), '# Test project\n'); + git(cwd, 'add', '.'); + git(cwd, 'commit', '-m', 'Initial project'); + git(cwd, 'remote', 'add', 'origin', origin); + git(cwd, 'push', '-u', 'origin', 'master'); + const baseSha = git(cwd, 'rev-parse', 'HEAD'); + const requests: ReturnType[0]['pullRequests']>['list']> = []; + const created: { branch: string; title: string; body: string }[] = []; + const pullRequests = { + list(_branch: string) { return requests; }, + create(branch: string, title: string, bodyFile: string) { + const url = `https://github.com/example/project/pull/${created.length + 1}`; + created.push({ branch, title, body: readFileSync(bodyFile, 'utf8') }); + requests.push({ url, state: 'OPEN', headRefName: branch, headRefOid: git(origin, 'rev-parse', `refs/heads/${branch}`), isCrossRepository: false }); + return url; + }, + }; + return { cwd, origin, baseSha, manifest, requests, created, options: { cwd, baseSha, repository: 'example/project', version: '0.1.0', pullRequests } }; +} + +describe('发布准备', () => { + test('脚本入口拒绝非主分支或非手动事件,不触及远端', () => { + for (const event of [ + { GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/feature' }, + { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/master' }, + ]) { + const result = spawnSync(process.execPath, [join(import.meta.dir, '../scripts/prepare-release.ts')], { + encoding: 'utf8', env: { ...process.env, ...event }, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('只能从 master 手动运行'); + } + }); + + test('只在新发布分支更新版本,主分支和工作区保持原内容', () => { + const project = fixture(); + const result = prepareRelease(project.options); + expect(result.branch).toBe('release/0.1.0'); + expect(result.pr_url).toBe('https://github.com/example/project/pull/1'); + expect(git(project.origin, 'rev-parse', 'refs/heads/master')).toBe(project.baseSha); + expect(git(project.cwd, 'rev-parse', 'HEAD')).toBe(project.baseSha); + expect(git(project.cwd, 'status', '--porcelain')).toBe(''); + expect(git(project.origin, 'show', '-s', '--format=%P', result.sha)).toBe(project.baseSha); + expect(git(project.origin, 'diff', '--name-only', project.baseSha, result.sha)).toBe('package.json'); + expect(JSON.parse(git(project.origin, 'show', `${result.sha}:package.json`))).toEqual({ ...project.manifest, version: '0.1.0' }); + expect(JSON.parse(readFileSync(join(project.cwd, 'package.json'), 'utf8'))).toEqual(project.manifest); + expect(git(project.origin, 'tag', '--list')).toBe(''); + expect(project.created[0]?.body).toContain('Approve workflows to run'); + expect(project.created[0]?.body).toContain('**Publish**'); + }, gitTestTimeout); + + test('同一提交和版本重跑恢复已有 PR,不追加提交或创建重复 PR', () => { + const project = fixture(); + const original = prepareRelease(project.options); + expect(prepareRelease(project.options)).toEqual(original); + expect(project.created).toHaveLength(1); + expect(git(project.origin, 'rev-list', '--count', 'master..release/0.1.0')).toBe('1'); + }, gitTestTimeout); + + test('推送后创建 PR 失败,可在重跑时从相同分支恢复', () => { + const project = fixture(); + expect(() => prepareRelease({ ...project.options, pullRequests: { + list: () => [], create: () => { throw new Error('PR API unavailable'); }, + } })).toThrow('PR API unavailable'); + const pushed = git(project.origin, 'rev-parse', 'refs/heads/release/0.1.0'); + expect(prepareRelease(project.options).sha).toBe(pushed); + expect(project.created).toHaveLength(1); + }, gitTestTimeout); + + test('拒绝无效、相同、回退和仅构建元数据不同的版本', () => { + const project = fixture('1.0.0'); + for (const version of ['v2.0.0', '2.0', '2.0.0;echo test', '2.0.0\n', '1.0.0', '0.9.9', '1.0.0-rc.1', '1.0.0+build.2']) { + expect(() => prepareRelease({ ...project.options, version })).toThrow(); + } + expect(git(project.origin, 'branch', '--list')).toBe('* master'); + expect(project.created).toHaveLength(0); + }, gitTestTimeout); + + test('接受从预发布版推进到正式版', () => { + const project = fixture('1.0.0-rc.1'); + expect(prepareRelease({ ...project.options, version: '1.0.0' }).branch).toBe('release/1.0.0'); + }, gitTestTimeout); + + test('已有标签时不创建准备分支', () => { + const project = fixture(); + git(project.cwd, 'tag', 'v0.1.0'); + git(project.cwd, 'push', 'origin', 'refs/tags/v0.1.0'); + expect(() => prepareRelease(project.options)).toThrow('标签 v0.1.0 已存在'); + expect(git(project.origin, 'branch', '--list')).toBe('* master'); + }, gitTestTimeout); + + test('不覆盖已有用户分支或准备后追加的用户改动', () => { + const project = fixture(); + const result = prepareRelease(project.options); + git(project.cwd, 'switch', '--detach', result.sha); + writeFileSync(join(project.cwd, 'README.md'), '# User update\n'); + git(project.cwd, 'add', 'README.md'); + git(project.cwd, 'commit', '-m', 'User change'); + const changed = git(project.cwd, 'rev-parse', 'HEAD'); + git(project.cwd, 'push', 'origin', 'HEAD:refs/heads/release/0.1.0'); + git(project.cwd, 'switch', 'master'); + expect(() => prepareRelease(project.options)).toThrow('拒绝覆盖已有分支'); + expect(git(project.origin, 'rev-parse', 'refs/heads/release/0.1.0')).toBe(changed); + expect(project.created).toHaveLength(1); + }, gitTestTimeout); + + test('同样的版本内容也不能冒充由工作流准备的分支', () => { + const project = fixture(); + git(project.cwd, 'switch', '-c', 'release/0.1.0'); + writeFileSync(join(project.cwd, 'package.json'), `${JSON.stringify({ ...project.manifest, version: '0.1.0' }, null, 2)}\n`); + git(project.cwd, 'add', 'package.json'); + git(project.cwd, 'commit', '-m', 'Manually update version'); + git(project.cwd, 'push', 'origin', 'release/0.1.0'); + const userCommit = git(project.cwd, 'rev-parse', 'HEAD'); + git(project.cwd, 'switch', 'master'); + expect(() => prepareRelease(project.options)).toThrow('拒绝覆盖已有分支'); + expect(git(project.origin, 'rev-parse', 'refs/heads/release/0.1.0')).toBe(userCommit); + }, gitTestTimeout); + + test('已有关闭的同版本 PR 或其他未合并发布 PR 时拒绝重复准备', () => { + const project = fixture(); + project.requests.push({ url: 'https://github.com/example/project/pull/8', state: 'OPEN', headRefName: 'release/0.2.0', headRefOid: project.baseSha, isCrossRepository: false }); + expect(() => prepareRelease(project.options)).toThrow('其他未合并的发布准备 PR'); + project.requests[0]!.headRefName = 'release/0.1.0'; + project.requests[0]!.state = 'CLOSED'; + expect(() => prepareRelease(project.options)).toThrow('已关联关闭'); + expect(git(project.origin, 'branch', '--list')).toBe('* master'); + }, gitTestTimeout); + + test('已关闭的其他版本与来自 fork 的同名 PR 不阻止准备', () => { + const project = fixture(); + project.requests.push( + { url: 'https://github.com/example/project/pull/8', state: 'CLOSED', headRefName: 'release/0.2.0', headRefOid: project.baseSha, isCrossRepository: false }, + { url: 'https://github.com/example/project/pull/9', state: 'OPEN', headRefName: 'release/0.1.0', headRefOid: project.baseSha, isCrossRepository: true }, + ); + expect(prepareRelease(project.options).branch).toBe('release/0.1.0'); + expect(project.created).toHaveLength(1); + }, gitTestTimeout); + + test('不改动脏工作区,且只能使用当前检出的指定主分支提交', () => { + const project = fixture(); + expect(() => prepareRelease({ ...project.options, baseSha: 'a'.repeat(40) })).toThrow('检出提交'); + writeFileSync(join(project.cwd, 'README.md'), '# Uncommitted work\n'); + expect(() => prepareRelease(project.options)).toThrow('未提交改动'); + expect(readFileSync(join(project.cwd, 'README.md'), 'utf8')).toBe('# Uncommitted work\n'); + expect(git(project.origin, 'branch', '--list')).toBe('* master'); + }, gitTestTimeout); +}); diff --git a/tests/publish-release.test.ts b/tests/publish-release.test.ts new file mode 100644 index 0000000..4425afe --- /dev/null +++ b/tests/publish-release.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { assertPublishRequest, pushReleaseTag, readReleaseDraft, verifyReleaseSource } from '../scripts/publish-release'; +import { assertReleaseLicense } from '../scripts/release'; + +// 实际 Git 进程集成测试需要覆盖不同平台的进程启动耗时 +const gitTestTimeout = 30_000; + +function repository() { + const directory = mkdtempSync(join(tmpdir(), 'java-run-publish-')); + const remote = join(directory, 'remote.git'); + const cwd = join(directory, 'checkout'); + function run(at: string, args: string[]) { + const result = spawnSync('git', args, { cwd: at, encoding: 'utf8' }); + if (result.error || result.status !== 0) throw new Error(result.error?.message ?? result.stderr); + return result.stdout.trim(); + } + const git = (...args: string[]) => run(cwd, args); + run(directory, ['init', '--bare', remote]); + run(directory, ['init', '--initial-branch=master', cwd]); + git('config', 'user.name', 'Release test'); + git('config', 'user.email', 'release@example.test'); + git('config', 'commit.gpgsign', 'false'); + git('config', 'tag.gpgsign', 'false'); + git('config', 'core.hooksPath', ''); + writeFileSync(join(cwd, 'package.json'), '{"name":"release-test","version":"1.2.3"}\n'); + git('add', 'package.json'); + git('commit', '-m', 'Initial version'); + git('remote', 'add', 'origin', remote); + git('push', '-u', 'origin', 'master'); + const sha = git('rev-parse', 'HEAD'); + return { cwd, remote, sha, git, run, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} + +describe('手动发布契约', () => { + test('仅接受 master 的手动请求与完整提交 SHA', () => { + const sha = 'a'.repeat(40); + expect(() => assertPublishRequest('workflow_dispatch', 'refs/heads/master', sha)).not.toThrow(); + for (const [event, ref, commit] of [ + ['push', 'refs/heads/master', sha], + ['workflow_dispatch', 'refs/heads/feature', sha], + ['workflow_dispatch', 'refs/tags/v1.2.3', sha], + ['workflow_dispatch', 'refs/heads/master', 'master'], + ['workflow_dispatch', 'refs/heads/master', undefined], + ]) expect(() => assertPublishRequest(event, ref, commit)).toThrow('master'); + }); + + test('缺失或空白许可证拒绝发布,允许三种许可证文件名', () => { + const directory = mkdtempSync(join(tmpdir(), 'java-run-license-')); + try { + expect(() => assertReleaseLicense(directory)).toThrow('LICENSE'); + writeFileSync(join(directory, 'LICENSE'), ' \n\t'); + expect(() => assertReleaseLicense(directory)).toThrow('LICENSE'); + for (const filename of ['LICENSE', 'LICENSE.md', 'LICENSE.txt']) { + writeFileSync(join(directory, filename), 'Test license\n'); + expect(() => assertReleaseLicense(directory)).not.toThrow(); + rmSync(join(directory, filename)); + } + } finally { rmSync(directory, { recursive: true, force: true }); } + }); + + test('验收提交创建附注标签,重跑不改标签对象', () => { + const repo = repository(); + try { + expect(verifyReleaseSource(repo.cwd, 'v1.2.3', repo.sha)).toEqual({ tagged: false }); + pushReleaseTag(repo.cwd, 'v1.2.3', repo.sha); + expect(repo.run(repo.remote, ['cat-file', '-t', 'refs/tags/v1.2.3'])).toBe('tag'); + expect(repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3^{}'])).toBe(repo.sha); + const tagObject = repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3']); + pushReleaseTag(repo.cwd, 'v1.2.3', repo.sha); + expect(repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3'])).toBe(tagObject); + expect(repo.git('status', '--porcelain')).toBe(''); + } finally { repo.cleanup(); } + }, gitTestTimeout); + + test('主分支前进后仍发布原先锁定的提交', () => { + const repo = repository(); + try { + repo.git('commit', '--allow-empty', '-m', 'Later feature'); + repo.git('push', 'origin', 'master'); + repo.git('checkout', '--detach', repo.sha); + pushReleaseTag(repo.cwd, 'v1.2.3', repo.sha); + expect(repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3^{}'])).toBe(repo.sha); + expect(repo.run(repo.remote, ['rev-parse', 'master'])).not.toBe(repo.sha); + } finally { repo.cleanup(); } + }, gitTestTimeout); + + test('同名轻量标签也必须精确匹配提交', () => { + const repo = repository(); + try { + repo.git('tag', 'v1.2.3'); + repo.git('push', 'origin', 'refs/tags/v1.2.3'); + expect(verifyReleaseSource(repo.cwd, 'v1.2.3', repo.sha)).toEqual({ tagged: true }); + repo.git('commit', '--allow-empty', '-m', 'Changed release source'); + repo.git('push', 'origin', 'master'); + const newer = repo.git('rev-parse', 'HEAD'); + expect(() => pushReleaseTag(repo.cwd, 'v1.2.3', newer)).toThrow('不能覆盖'); + expect(repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3'])).toBe(repo.sha); + } finally { repo.cleanup(); } + }, gitTestTimeout); + + test('附注标签冲突、错误检出和未合入主分支的提交均拒绝发布', () => { + const repo = repository(); + try { + pushReleaseTag(repo.cwd, 'v1.2.3', repo.sha); + repo.git('switch', '-c', 'feature'); + repo.git('commit', '--allow-empty', '-m', 'Unmerged change'); + const feature = repo.git('rev-parse', 'HEAD'); + expect(() => verifyReleaseSource(repo.cwd, 'v1.2.3', repo.sha)).toThrow('锁定'); + expect(() => verifyReleaseSource(repo.cwd, 'v1.2.3', feature)).toThrow('不属于'); + repo.git('switch', 'master'); + repo.git('merge', '--ff-only', 'feature'); + repo.git('push', 'origin', 'master'); + expect(() => pushReleaseTag(repo.cwd, 'v1.2.3', feature)).toThrow('不能覆盖'); + expect(repo.run(repo.remote, ['rev-parse', 'refs/tags/v1.2.3^{}'])).toBe(repo.sha); + } finally { repo.cleanup(); } + }, gitTestTimeout); + + test('标签必须匹配包版本,远端查询失败不能当成标签不存在', () => { + const repo = repository(); + try { + expect(() => pushReleaseTag(repo.cwd, 'v1.2.4', repo.sha)).toThrow('版本不一致'); + repo.git('remote', 'set-url', 'origin', join(repo.cwd, 'missing.git')); + expect(() => pushReleaseTag(repo.cwd, 'v1.2.3', repo.sha)).toThrow('git 执行失败'); + expect(repo.run(repo.remote, ['tag', '--list'])).toBe(''); + } finally { repo.cleanup(); } + }, gitTestTimeout); + + test('Release 草稿查询可恢复,错误响应与公开版本拒绝继续', async () => { + const request = (status: number, body: unknown) => async () => new Response(JSON.stringify(body), { status }); + const response = (release: unknown) => ({ data: { repository: { release } } }); + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, response(null)))).resolves.toBeUndefined(); + for (const status of [401, 403, 404, 429, 500]) { + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(status, {}))).rejects.toThrow(`HTTP ${status}`); + } + const draft = { databaseId: 42, tagName: 'v1.2.3', isDraft: true, + releaseAssets: { nodes: [{ name: 'partial.zip' }], pageInfo: { hasNextPage: false } } }; + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, response(draft)))).resolves.toEqual({ + id: 42, draft: true, assets: [{ name: 'partial.zip' }], + }); + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, response({ ...draft, isDraft: false })))).rejects.toThrow('已公开'); + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, response({ ...draft, tagName: 'v1.2.4' })))).rejects.toThrow('标签'); + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, { errors: [{ message: 'Denied' }] }))).rejects.toThrow('GitHub'); + await expect(readReleaseDraft('owner/repo', 'v1.2.3', 'test', request(200, { data: { repository: null } }))).rejects.toThrow('仓库'); + }); + + test('普通演练不受标签环境影响,未知元数据选项被拒绝', () => { + const root = join(import.meta.dir, '..'); + const script = join(root, 'scripts/release.ts'); + const env = { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v999.0.0', GITHUB_OUTPUT: '' }; + const preview = spawnSync(process.execPath, [script, 'metadata'], { cwd: root, env, encoding: 'utf8' }); + expect(preview.status).toBe(0); + expect(JSON.parse(preview.stdout).version).toBe(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version); + const invalid = spawnSync(process.execPath, [script, 'metadata', '--unknown'], { cwd: root, env, encoding: 'utf8' }); + expect(invalid.status).toBe(1); + }); +});