Repository navigation
366 lines (337 loc) · 17.5 KB
/
Copy pathrelease.yml
File metadata and controls
366 lines (337 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
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
# 只为发布那一步:setup-node 会往项目 .npmrc 写 //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN},
# 凭据在下面那一步的 env 里给(与 publish-packages.yml 同一条路数)。
registry-url: https://registry.npmjs.org
- 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
# 包名是 npm 上的 @aidotnet/nextcowork-device-service,`npm pack` 出来的文件名把作用域拍平
# (`@scope/name` → `scope-name`),所以这里的通配符要跟着走,否则 Release 会传空资产而静默失败。
- name: Write checksum
run: cd out && sha256sum aidotnet-nextcowork-device-service-*.tgz > aidotnet-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/aidotnet-nextcowork-device-service-*.tgz out/aidotnet-nextcowork-device-service.sha256 --clobber
# 顺手把同一份产物发到 npm(`npm i -g @aidotnet/nextcowork-device-service`),用户不必从
# Release 下 tgz 再自己装依赖。只跟着真正的发版跑:build-only 的 workflow_dispatch 不碰 npm。
# 名字与版本读的是打包时生成的那份清单,不在这里重写一遍。
- name: Publish to npm
if: needs.prepare.outputs.publish == 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
manifest=out/agent-package/package.json
name=$(node -p "JSON.parse(require('node:fs').readFileSync('$manifest','utf8')).name")
version=$(node -p "JSON.parse(require('node:fs').readFileSync('$manifest','utf8')).version")
# npm 不允许覆盖已发布的版本号。与上面 gh release upload 的 --clobber 对齐:同一个 tag
# 重跑应当是幂等的;真要换内容只能升版本号,这是 npm 的规矩。
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "$name@$version is already on npm; nothing to publish"
else
# ★ 文件名前面必须有 `./`。npm 把**路径**与 GitHub 简写(owner/repo)一起解析,而
# `out/aidotnet-nextcowork-device-service-2.7.2.tgz` 正好长得像后者 —— 不带 `./` 时
# npm 去 `ssh://git@github.com/out/…tgz.git` 取包,报的是「Permission denied (publickey)」,
# 看起来像凭据问题,其实是路径被当成了仓库名。只传 Tarball 文件名也**不行**:同样落进
# GitHub 简写,这里必须带目录前缀。
npm publish ./out/aidotnet-nextcowork-device-service-*.tgz --access public
fi
- name: Upload build-only artifact
if: needs.prepare.outputs.publish != 'true'
uses: actions/upload-artifact@v4
with:
name: aidotnet-nextcowork-device-service
path: |
out/aidotnet-nextcowork-device-service-*.tgz
out/aidotnet-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