Repository navigation
chore: 发布 v2.7.0 #32
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build desktop releases | |
| on: | |
| push: | |
| tags: | |
| - 'v*.*.*' | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: 'Existing version tag to publish (leave empty for a build-only run)' | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: release-${{ inputs.release_tag || github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| prepare: | |
| name: Prepare release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| tag: ${{ steps.target.outputs.tag }} | |
| source_sha: ${{ steps.target.outputs.source_sha }} | |
| publish: ${{ steps.target.outputs.publish }} | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ inputs.release_tag || github.ref }} | |
| - name: Resolve and validate release target | |
| id: target | |
| env: | |
| TAG_NAME: ${{ inputs.release_tag || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || '' }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' | |
| import { execFileSync } from 'node:child_process' | |
| import { join } from 'node:path' | |
| const tag = process.env.TAG_NAME | |
| const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() | |
| if (tag) { | |
| const { version } = JSON.parse(readFileSync('package.json', 'utf8')) | |
| if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag) || tag !== `v${version}`) { | |
| throw new Error(`Release tag ${tag} does not match package version ${version}`) | |
| } | |
| const tagSha = execFileSync('git', ['rev-parse', `refs/tags/${tag}^{commit}`], { encoding: 'utf8' }).trim() | |
| if (tagSha !== sha) throw new Error('The checked-out commit does not match the release tag') | |
| const lines = readFileSync('CHANGELOG.md', 'utf8').split(/\r?\n/) | |
| const start = lines.indexOf(`## ${tag}`) | |
| if (start < 0) throw new Error(`No changelog entry found for ${tag}`) | |
| const rest = lines.slice(start + 1) | |
| const end = rest.findIndex(line => line.startsWith('## ')) | |
| const notes = rest.slice(0, end < 0 ? undefined : end).join('\n').trim() | |
| if (!notes) throw new Error(`Empty changelog entry for ${tag}`) | |
| writeFileSync(join(process.env.RUNNER_TEMP, 'release-notes.md'), `${notes}\n`) | |
| } | |
| appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\nsource_sha=${sha}\npublish=${Boolean(tag)}\n`) | |
| NODE | |
| - name: Prepare draft Release | |
| if: steps.target.outputs.publish == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG_NAME: ${{ steps.target.outputs.tag }} | |
| run: | | |
| if gh release view "$TAG_NAME" >/dev/null 2>&1; then | |
| gh release edit "$TAG_NAME" --title "$TAG_NAME" --notes-file "$RUNNER_TEMP/release-notes.md" | |
| else | |
| gh release create "$TAG_NAME" --verify-tag --draft \ | |
| --title "$TAG_NAME" --notes-file "$RUNNER_TEMP/release-notes.md" | |
| fi | |
| build: | |
| name: Build ${{ matrix.name }} | |
| needs: prepare | |
| runs-on: ${{ matrix.runner }} | |
| # 需求:30 分钟在 macOS 上不够。Build application 一步包含 electron-builder 的 | |
| # dmg+zip 双目标构建、签名与公证(公证每份产物都要等 Apple 的队列,一个 job 两份), | |
| # v2.2.4 首次启用公证后两个 macOS job 都在 30:20 被 timeout 掐死,publish 因此被跳过、 | |
| # Release 永远停在草稿。Windows/Ubuntu 实测 6 分钟内完成,上调只放宽上限,不拖慢它们。 | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: Windows | |
| id: windows | |
| platform: windows | |
| runner: windows-latest | |
| arch: x64 | |
| artifact_path: dist/*.exe | |
| - name: Ubuntu | |
| id: linux | |
| platform: linux | |
| runner: ubuntu-latest | |
| arch: x64 | |
| artifact_path: dist/*.AppImage | |
| - name: macOS Apple Silicon | |
| id: macos-arm64 | |
| platform: macos | |
| runner: macos-15 | |
| arch: arm64 | |
| artifact_path: | | |
| dist/*-arm64-mac.dmg | |
| dist/*-arm64-mac.zip | |
| - name: macOS Intel | |
| id: macos-x64 | |
| platform: macos | |
| runner: macos-15-intel | |
| arch: x64 | |
| artifact_path: | | |
| dist/*-x64-mac.dmg | |
| dist/*-x64-mac.zip | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.prepare.outputs.source_sha }} | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| # Keep CI and packaging on a runtime that provides node:sqlite. | |
| node-version: 24.16.0 | |
| cache: npm | |
| - name: Install dependencies | |
| # ★★ 这里必须重试:v2.2.11 的 macOS Intel job 就死在这一步,报 | |
| # `ConnectTimeoutError: www.electronjs.org:443 (timeout: 10000ms)` —— | |
| # 而 Publish 因此被跳过、Release 永远停在草稿,另外三台同样的步骤却过了。 | |
| # | |
| # 这一步的 postinstall 会让 electron-builder 用 node-gyp 为 Electron | |
| # 重编译 node-pty,headers 只能取自 `www.electronjs.org/headers`,而那个 | |
| # 地址**改不掉**:@electron/rebuild 把它作为**命令行参数** --dist-url 传给 | |
| # node-gyp(rebuild.js 里的默认值),electron-builder 调的是它的 API、不传 | |
| # headerURL;electron-builder 自己的镜像配置(getGypEnv 读 | |
| # npm_config_electron_mirror)只作用于它那条 legacy 路径。所以 .npmrc 里的 | |
| # electron_mirror 在这一步用不上 —— 它照旧管着 electron 二进制的下载, | |
| # npm 11 那句 `Unknown project config` 只是警告,不是失效。 | |
| # | |
| # 把 node-gyp 顶到 npmmirror 也走不通:实测那边 v44.1.1 的 SHASUMS256.txt | |
| # 里没有 `node-v44.1.1-headers.tar.gz`,node-gyp 会以 | |
| # `local checksum … not match remote undefined` 回滚(headers 只发在 | |
| # electronjs.org/headers)。于是这条链路就是「外部依赖 + 10 秒连接超时」, | |
| # 唯一可靠的办法是重试:瞬时失败不再废掉整轮发布,而确定性的编译错误 | |
| # 三次都失败、照样红。 | |
| shell: bash | |
| run: | | |
| for attempt in 1 2 3; do | |
| if npm ci --no-audit --no-fund; then exit 0; fi | |
| echo "::warning::npm ci 第 ${attempt} 次失败(多为 electron headers 下载超时),重试" | |
| sleep $((attempt * 20)) | |
| done | |
| exit 1 | |
| - name: Check macOS signing credentials | |
| # 需求:在打包前把缺失的 secret 一次性点名。 | |
| # 原先 Windows 与 macOS 都故意不签名,因为当时两个平台都没有证书;现在 macOS 有 | |
| # Developer ID 了,不签名的包既过不了 Gatekeeper,也过不了 Squirrel.Mac 的 | |
| # designated requirement 校验(更新包下载成功但装不上,见 src/main/update/update-service.ts)。 | |
| # 而"签名成功、公证被静默跳过"是最贵的失败:CI 全绿,用户拿到的包依旧报警。 | |
| # 不满足会怎样:构建日志里只有一行 "skipped macOS notarization",没人会注意到。 | |
| # 拆除条件:改用 App Store Connect API Key 公证时,把下面的名字换成 APPLE_API_KEY 那三个。 | |
| if: matrix.platform == 'macos' | |
| env: | |
| CSC_LINK: ${{ secrets.CSC_LINK }} | |
| CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| missing='' | |
| for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do | |
| if [ -z "${!name}" ]; then missing="$missing $name"; fi | |
| done | |
| if [ -n "$missing" ]; then | |
| echo "::error::Missing repository secrets:$missing" | |
| exit 1 | |
| fi | |
| echo "macOS signing credentials present." | |
| - name: Build application | |
| run: npm run dist:ci -- --${{ matrix.arch }} | |
| env: | |
| # ★ 这一行必须对 macOS 打开。关掉它时 findIdentity() 在没有 CSC_NAME 的情况下直接 | |
| # 返回 null,macPackager 随即 return false —— 签名和公证会一起静默跳过, | |
| # 连 CSC_LINK 都不会被读。Windows / Linux 仍然保持未签名(本流程的既有决定)。 | |
| CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.platform == 'macos' && 'true' || 'false' }} | |
| # 只在 macOS 行注入证书。CSC_LINK 不能设成全局:Windows 上 getCscLink() 会退回到 | |
| # CSC_LINK(platformPackager),拿 mac 的 p12 去签 exe 必然失败。 | |
| CSC_LINK: ${{ matrix.platform == 'macos' && secrets.CSC_LINK || '' }} | |
| CSC_KEY_PASSWORD: ${{ matrix.platform == 'macos' && secrets.CSC_KEY_PASSWORD || '' }} | |
| APPLE_ID: ${{ matrix.platform == 'macos' && secrets.APPLE_ID || '' }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }} | |
| APPLE_TEAM_ID: ${{ matrix.platform == 'macos' && secrets.APPLE_TEAM_ID || '' }} | |
| - name: Upload platform assets to Release | |
| if: needs.prepare.outputs.publish == 'true' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG_NAME: ${{ needs.prepare.outputs.tag }} | |
| BUILD_PLATFORM: ${{ matrix.platform }} | |
| BUILD_ARCH: ${{ matrix.arch }} | |
| run: | | |
| node --input-type=module <<'NODE' | |
| import { execFileSync } from 'node:child_process' | |
| import { ARTIFACTS, collectArtifacts } from './scripts/upload-client-release.mjs' | |
| const { TAG_NAME: tag, BUILD_PLATFORM: platform, BUILD_ARCH: architecture } = process.env | |
| const { found } = await collectArtifacts('dist', false, tag.slice(1)) | |
| const expected = ARTIFACTS.filter(item => item.platform === platform && item.architecture === architecture) | |
| const assets = found.filter(item => item.platform === platform && item.architecture === architecture) | |
| if (expected.length === 0 || assets.length !== expected.length) { | |
| throw new Error(`Missing release assets for ${platform}/${architecture}`) | |
| } | |
| execFileSync('gh', ['release', 'upload', tag, ...assets.map(item => item.path), '--clobber'], { stdio: 'inherit' }) | |
| NODE | |
| - name: Upload build-only artifacts | |
| if: needs.prepare.outputs.publish != 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: nextcowork-${{ matrix.id }} | |
| path: ${{ matrix.artifact_path }} | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # 独立设备服务包(无 Electron,Node >= 24):随桌面版本同一个 Release 发布,**手动下载安装**, | |
| # 没有自动更新通道(桌面更新器只认 exe/AppImage/dmg/zip,见 scripts/upload-client-release.mjs)。 | |
| # 包里没有任何机密;依赖由 `npm install --omit=dev` 在目标机上安装。 | |
| device-service: | |
| name: Device service package | |
| needs: prepare | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.prepare.outputs.source_sha }} | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24.16.0 | |
| cache: npm | |
| - name: Install dependencies | |
| # 只需要 vite/tsc 构建 bundle;跳过 postinstall,避免为 Electron 重编译原生模块 | |
| # (那一步要下载 electronjs.org headers,见 build 任务里的说明)。 | |
| run: npm ci --no-audit --no-fund --ignore-scripts | |
| - name: Build, package and verify outside the repository | |
| run: npm run build:agent && npm run package:agent -- --verify | |
| - name: Write checksum | |
| run: cd out && sha256sum nextcowork-device-service-*.tgz > nextcowork-device-service.sha256 | |
| - name: Upload to Release | |
| if: needs.prepare.outputs.publish == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG_NAME: ${{ needs.prepare.outputs.tag }} | |
| run: gh release upload "$TAG_NAME" out/nextcowork-device-service-*.tgz out/nextcowork-device-service.sha256 --clobber | |
| - name: Upload build-only artifact | |
| if: needs.prepare.outputs.publish != 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: nextcowork-device-service | |
| path: | | |
| out/nextcowork-device-service-*.tgz | |
| out/nextcowork-device-service.sha256 | |
| if-no-files-found: error | |
| retention-days: 1 | |
| publish: | |
| name: Publish GitHub Release | |
| if: needs.prepare.outputs.publish == 'true' | |
| needs: [prepare, build, device-service] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| TAG_NAME: ${{ needs.prepare.outputs.tag }} | |
| steps: | |
| - name: Check out release tag | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.prepare.outputs.source_sha }} | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24.16.0 | |
| - name: Download platform artifacts | |
| run: gh release download "$TAG_NAME" --dir release-assets --pattern '*.exe' --pattern '*.AppImage' --pattern '*-mac.dmg' --pattern '*-mac.zip' | |
| - name: Verify client artifacts before publishing | |
| run: node scripts/upload-client-release.mjs --dir release-assets --version "${TAG_NAME#v}" --require-all --dry-run | |
| - name: Publish complete Release | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| awk -v tag="$TAG_NAME" ' | |
| $0 == "## " tag { found = 1; next } | |
| found && /^## / { exit } | |
| found { print } | |
| ' CHANGELOG.md > "$RUNNER_TEMP/release-notes.md" | |
| if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/release-notes.md"; then | |
| echo "No changelog entry found for $TAG_NAME" >&2 | |
| exit 1 | |
| fi | |
| gh release edit "$TAG_NAME" --draft=false --title "$TAG_NAME" --notes-file "$RUNNER_TEMP/release-notes.md" | |
| - name: Upload client artifacts to NextCoWork API | |
| env: | |
| CLIENT_UPLOAD_TOKEN: ${{ secrets.CLIENT_UPLOAD_TOKEN }} | |
| CLIENT_UPDATE_BASE_URL: https://nextco.work | |
| # Notes used to be a bare GitHub Release URL: the API read them from a plain HTTP header, | |
| # which cannot carry multiline Chinese text. The upload script now base64-encodes the full | |
| # changelog section via X-Client-Release-Notes-B64, so the client receives the notes itself | |
| # (the electron-updater feed only forwards fields present in latest*.yml — see the feed | |
| # endpoint, which now emits releaseNotes/mandatory/minimumSupportedVersion/graceUntil). | |
| # release-notes.md is cut from CHANGELOG.md by the "Publish complete Release" step above. | |
| run: node scripts/upload-client-release.mjs --dir release-assets --version "${TAG_NAME#v}" --base-url "$CLIENT_UPDATE_BASE_URL" --notes-file "$RUNNER_TEMP/release-notes.md" --require-all --ignore-duplicates |