-
Notifications
You must be signed in to change notification settings - Fork 129
287 lines (266 loc) · 13.3 KB
/
Copy pathrelease.yml
File metadata and controls
287 lines (266 loc) · 13.3 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
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
publish:
name: Publish GitHub Release
if: needs.prepare.outputs.publish == 'true'
needs: [prepare, build]
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