ssh-release 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +51 -5
- package/dist/cli.js +84 -10
- package/dist/manifest.js +86 -0
- package/dist/release.js +142 -13
- package/dist/rollback.js +45 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 - 2026-06-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增强 `deploy --dry-run` 并新增 `deploy --plan`,预览上传、manifest、切换、清理和校验计划。
|
|
8
|
+
- 增强 `rollback --dry-run` 并新增 `rollback --plan`,预览回滚目标、`current` 切换和校验项。
|
|
9
|
+
- 增加常见失败的下一步提示,覆盖远端锁、回滚目标、发布校验和 SSH 连接错误。
|
|
10
|
+
- 增加发布 manifest,发布时生成并上传 `manifest.json`,记录文件清单、大小和 SHA-256。
|
|
11
|
+
- 增强发布后校验,远端 `manifest.json` 会进行 hash 校验。
|
|
12
|
+
|
|
13
|
+
## 1.0.2 - 2026-06-26
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 增强 npm 自动发布 workflow,发布后会验证 registry 版本和安装后的 CLI 入口。
|
|
18
|
+
|
|
3
19
|
## 1.0.1 - 2026-06-25
|
|
4
20
|
|
|
5
21
|
### Added
|
package/README.md
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
- `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
|
|
19
19
|
- `--json`:输出单行 JSON,便于 CI/CD 解析。
|
|
20
20
|
- `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
|
|
21
|
-
-
|
|
21
|
+
- `deploy --plan`:不连接远端、不修改服务器,预览上传、切换、清理和校验计划。
|
|
22
|
+
- `rollback --plan`:连接远端读取版本状态,但不修改服务器,预览回滚切换计划。
|
|
23
|
+
- 失败下一步提示:常见锁、回滚目标、发布校验和 SSH 错误会提示下一步操作。
|
|
24
|
+
- 发布 manifest:每次发布生成 `manifest.json`,记录版本、发布时间、本地来源、文件清单、文件大小和 SHA-256。
|
|
25
|
+
- 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、`manifest.json` hash 匹配、远端锁已清理。
|
|
22
26
|
- `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
|
|
23
27
|
- `overwrite` 模式:直接覆盖发布到目标目录。
|
|
24
28
|
- 远端 `tar` 解压失败时回退逐文件上传。
|
|
@@ -179,6 +183,7 @@ ssh-release doctor
|
|
|
179
183
|
|
|
180
184
|
```bash
|
|
181
185
|
ssh-release deploy --dry-run
|
|
186
|
+
ssh-release deploy --plan
|
|
182
187
|
```
|
|
183
188
|
|
|
184
189
|
执行发布:
|
|
@@ -187,9 +192,30 @@ ssh-release deploy --dry-run
|
|
|
187
192
|
ssh-release deploy
|
|
188
193
|
```
|
|
189
194
|
|
|
190
|
-
`
|
|
195
|
+
`deploy --dry-run` 和 `deploy --plan` 都不会连接远端或修改服务器,会预览:
|
|
191
196
|
|
|
192
|
-
|
|
197
|
+
- 将上传的本地来源和远端临时压缩包路径。
|
|
198
|
+
- 将写入的远端 `manifest.json` 路径、文件数量和总字节数。
|
|
199
|
+
- `release` 模式下将切换的 `current` 目标。
|
|
200
|
+
- 将清理的远端临时压缩包,以及旧版本保留策略。
|
|
201
|
+
- 发布后会执行的远端校验项。
|
|
202
|
+
|
|
203
|
+
`release` 模式发布成功后会输出版本号、版本目录、`current` 软链接路径和远端 `manifest.json` 路径。只有压缩包上传、解压和发布清单上传完成后才切换 `current`。
|
|
204
|
+
|
|
205
|
+
发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、发布清单 hash 匹配、远端锁已清理;`overwrite` 模式会确认目标目录存在、发布清单 hash 匹配、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
|
|
206
|
+
|
|
207
|
+
`manifest.json` 会写入发布目标目录:
|
|
208
|
+
|
|
209
|
+
- `release` 模式:`<target.path>/<releasesDir>/<version>/manifest.json`
|
|
210
|
+
- `overwrite` 模式:`<target.path>/manifest.json`
|
|
211
|
+
|
|
212
|
+
清单内容包含:
|
|
213
|
+
|
|
214
|
+
- `version`:本次发布版本号。
|
|
215
|
+
- `createdAt`:发布时间。
|
|
216
|
+
- `source`:配置中的本地来源路径、来源类型和排除规则。
|
|
217
|
+
- `files`:每个文件的相对路径、字节数和 SHA-256。
|
|
218
|
+
- `totals`:文件数量和总字节数。
|
|
193
219
|
|
|
194
220
|
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
|
|
195
221
|
|
|
@@ -227,6 +253,20 @@ ssh-release rollback
|
|
|
227
253
|
ssh-release rollback 20260625-150000
|
|
228
254
|
```
|
|
229
255
|
|
|
256
|
+
需要先查看回滚计划但不切换 `current` 时:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
ssh-release rollback [version] --dry-run
|
|
260
|
+
ssh-release rollback [version] --plan
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`rollback --dry-run` 和 `rollback --plan` 会连接远端读取锁状态、版本列表和当前版本,但不会创建锁、不会切换 `current`、不会删除任何版本目录。预览会显示:
|
|
264
|
+
|
|
265
|
+
- 当前版本和目标版本。
|
|
266
|
+
- 将切换的 `current` 路径和目标。
|
|
267
|
+
- 回滚不会删除版本目录。
|
|
268
|
+
- 实际回滚前需要满足的校验项。
|
|
269
|
+
|
|
230
270
|
`overwrite` 模式没有版本列表,也不支持回滚。
|
|
231
271
|
|
|
232
272
|
## JSON 输出
|
|
@@ -235,6 +275,7 @@ ssh-release rollback 20260625-150000
|
|
|
235
275
|
|
|
236
276
|
```bash
|
|
237
277
|
ssh-release deploy --dry-run --json
|
|
278
|
+
ssh-release deploy --plan --json
|
|
238
279
|
ssh-release deploy --json --progress
|
|
239
280
|
ssh-release doctor --json
|
|
240
281
|
ssh-release list --json
|
|
@@ -245,7 +286,7 @@ ssh-release unlock --json
|
|
|
245
286
|
成功时输出单行 JSON:
|
|
246
287
|
|
|
247
288
|
```json
|
|
248
|
-
{"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
|
|
289
|
+
{"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","manifest":{"remotePath":"/var/www/my-app/releases/20260625-153000/manifest.json","fileCount":12,"totalBytes":34567,"sha256":"..."},"verified":true}}
|
|
249
290
|
```
|
|
250
291
|
|
|
251
292
|
命令执行失败时输出:
|
|
@@ -273,7 +314,7 @@ ssh-release deploy --json --progress
|
|
|
273
314
|
发布结果中的 `verification` 会列出已通过的远端校验项:
|
|
274
315
|
|
|
275
316
|
```json
|
|
276
|
-
{"verified":true,"verification":[{"name":"
|
|
317
|
+
{"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
|
|
277
318
|
```
|
|
278
319
|
|
|
279
320
|
`doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
|
|
@@ -318,6 +359,7 @@ export SSH_RELEASE_PASSWORD='your-password'
|
|
|
318
359
|
- `tar`:本地打包发布内容。
|
|
319
360
|
- `ssh`:执行远端目录、解压、软链接、列表和检查命令。
|
|
320
361
|
- `scp`:上传压缩包和逐文件回退上传。
|
|
362
|
+
- `sha256sum` 或 `shasum`:发布后校验远端 `manifest.json` hash。
|
|
321
363
|
- `sshpass`:仅密码登录时需要;私钥登录不需要。
|
|
322
364
|
|
|
323
365
|
在 macOS 上打包时会禁用 AppleDouble 和扩展属性元数据,避免把 `._*` 文件发布到 Linux 服务器。
|
|
@@ -339,6 +381,7 @@ npm run build
|
|
|
339
381
|
- `npm run build`:编译 `dist/`。
|
|
340
382
|
- GitHub Actions 会在 `main` 推送和 Pull Request 上运行 `npm ci`、`lint`、`test` 和 `build`。
|
|
341
383
|
- 推送 `v*` 标签会触发 npm 自动发布 workflow。发布使用 npm Trusted Publishing,不需要长期 `NPM_TOKEN`。
|
|
384
|
+
- 发布 workflow 会在 `npm publish` 后验证 npm registry 的当前版本和 `latest`,并从 npm 重新安装当前版本做 CLI 烟测。
|
|
342
385
|
|
|
343
386
|
## 发布前检查
|
|
344
387
|
|
|
@@ -385,6 +428,7 @@ src/
|
|
|
385
428
|
├── doctor.ts
|
|
386
429
|
├── lock.ts
|
|
387
430
|
├── list.ts
|
|
431
|
+
├── manifest.ts
|
|
388
432
|
├── package.ts
|
|
389
433
|
├── process.ts
|
|
390
434
|
├── remote.ts
|
|
@@ -402,6 +446,7 @@ tests/
|
|
|
402
446
|
├── deploy.test.ts
|
|
403
447
|
├── e2e.test.ts
|
|
404
448
|
├── list-doctor.test.ts
|
|
449
|
+
├── manifest.test.ts
|
|
405
450
|
├── package-json.test.ts
|
|
406
451
|
├── package.test.ts
|
|
407
452
|
├── release.test.ts
|
|
@@ -433,6 +478,7 @@ docs/
|
|
|
433
478
|
- macOS AppleDouble 元数据排除。
|
|
434
479
|
- `release` 和 `overwrite` 发布流程。
|
|
435
480
|
- 发布和回滚锁获取、释放和锁冲突拦截。
|
|
481
|
+
- 发布 manifest 生成、上传和远端 hash 校验。
|
|
436
482
|
- `doctor` 远端锁状态检查和安全清理提示。
|
|
437
483
|
- `unlock` 显式确认路径后删除远端锁。
|
|
438
484
|
- 远端 `tar` 失败后的逐文件上传回退。
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.
|
|
|
6
6
|
import { runDoctorFromFile } from './doctor.js';
|
|
7
7
|
import { listReleases } from './list.js';
|
|
8
8
|
import { createDeployPlan, deploy, } from './release.js';
|
|
9
|
-
import { rollback } from './rollback.js';
|
|
9
|
+
import { createRollbackPlan, rollback } from './rollback.js';
|
|
10
10
|
import { createRemoteClient } from './ssh.js';
|
|
11
11
|
import { unlock } from './unlock.js';
|
|
12
12
|
export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
@@ -72,7 +72,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
72
72
|
return 0;
|
|
73
73
|
}
|
|
74
74
|
if (command === 'rollback') {
|
|
75
|
-
const result = await handlers.rollback(args[0]);
|
|
75
|
+
const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
|
|
76
76
|
if (parsed.json) {
|
|
77
77
|
printJsonResult('rollback', result, 0, io);
|
|
78
78
|
return 0;
|
|
@@ -121,11 +121,16 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
121
121
|
return command ? 1 : 0;
|
|
122
122
|
}
|
|
123
123
|
catch (error) {
|
|
124
|
+
const errorMessage = formatError(error);
|
|
125
|
+
const hint = createErrorHint(command, errorMessage);
|
|
124
126
|
if (parsed.json) {
|
|
125
|
-
printJsonError(command,
|
|
127
|
+
printJsonError(command, errorMessage, io, hint);
|
|
126
128
|
return 1;
|
|
127
129
|
}
|
|
128
|
-
io.error(
|
|
130
|
+
io.error(errorMessage);
|
|
131
|
+
if (hint) {
|
|
132
|
+
io.error(`下一步: ${hint}`);
|
|
133
|
+
}
|
|
129
134
|
return 1;
|
|
130
135
|
}
|
|
131
136
|
}
|
|
@@ -165,7 +170,7 @@ function parseCliArgs(argv) {
|
|
|
165
170
|
index += 1;
|
|
166
171
|
continue;
|
|
167
172
|
}
|
|
168
|
-
if (arg === '--dry-run') {
|
|
173
|
+
if (arg === '--dry-run' || arg === '--plan') {
|
|
169
174
|
dryRun = true;
|
|
170
175
|
continue;
|
|
171
176
|
}
|
|
@@ -225,8 +230,11 @@ function createDefaultHandlers(configPath) {
|
|
|
225
230
|
onProgress: options?.onProgress,
|
|
226
231
|
});
|
|
227
232
|
},
|
|
228
|
-
rollback: async (version) => {
|
|
233
|
+
rollback: async (version, options) => {
|
|
229
234
|
const config = await loadConfigFile(configPath);
|
|
235
|
+
if (options?.dryRun) {
|
|
236
|
+
return createRollbackPlan(config, createRemoteClient(config), version);
|
|
237
|
+
}
|
|
230
238
|
return rollback(config, createRemoteClient(config), version);
|
|
231
239
|
},
|
|
232
240
|
list: async () => {
|
|
@@ -248,12 +256,23 @@ function printDeployResult(result, io) {
|
|
|
248
256
|
io.log(`模式: ${result.mode}`);
|
|
249
257
|
io.log(`源路径: ${result.sourcePath}`);
|
|
250
258
|
io.log(`目标目录: ${result.targetPath}`);
|
|
259
|
+
io.log(`计划上传: ${result.upload.sourcePath} -> ${result.upload.archivePath}`);
|
|
260
|
+
io.log(`计划清单: ${result.upload.manifestPath}`);
|
|
261
|
+
io.log(`计划文件: ${result.upload.fileCount} 个,${result.upload.totalBytes} 字节`);
|
|
251
262
|
if (result.version) {
|
|
252
263
|
io.log(`计划版本: ${result.version}`);
|
|
253
264
|
}
|
|
254
|
-
if (result.
|
|
265
|
+
if (result.switch) {
|
|
266
|
+
io.log(`计划切换: ${result.switch.currentSymlink} -> ${result.switch.target}`);
|
|
267
|
+
}
|
|
268
|
+
else if (result.currentSymlink) {
|
|
255
269
|
io.log(`计划切换: ${result.currentSymlink}`);
|
|
256
270
|
}
|
|
271
|
+
io.log(`计划清理: ${result.cleanup.tempArchivePath}`);
|
|
272
|
+
io.log(`版本保留: ${result.cleanup.oldReleases}`);
|
|
273
|
+
for (const verification of result.verification) {
|
|
274
|
+
io.log(`计划校验: ${verification}`);
|
|
275
|
+
}
|
|
257
276
|
return;
|
|
258
277
|
}
|
|
259
278
|
if (result.mode === 'release') {
|
|
@@ -268,6 +287,10 @@ function printDeployResult(result, io) {
|
|
|
268
287
|
if (result.usedFallback) {
|
|
269
288
|
io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
|
|
270
289
|
}
|
|
290
|
+
if (result.manifest) {
|
|
291
|
+
io.log(`发布清单: ${result.manifest.remotePath}`);
|
|
292
|
+
io.log(`清单文件数: ${result.manifest.fileCount}`);
|
|
293
|
+
}
|
|
271
294
|
if (result.verified) {
|
|
272
295
|
io.log('远端校验通过');
|
|
273
296
|
for (const check of result.verification ?? []) {
|
|
@@ -279,6 +302,18 @@ function printDeployResult(result, io) {
|
|
|
279
302
|
}
|
|
280
303
|
}
|
|
281
304
|
function printRollbackResult(result, io) {
|
|
305
|
+
if ('dryRun' in result) {
|
|
306
|
+
io.log('回滚预检通过,不会修改远程服务器');
|
|
307
|
+
io.log(`当前版本: ${result.currentVersion}`);
|
|
308
|
+
io.log(`目标版本: ${result.version}`);
|
|
309
|
+
io.log(`目标目录: ${result.targetPath}`);
|
|
310
|
+
io.log(`计划切换: ${result.switch.currentSymlink}: ${result.switch.from} -> ${result.switch.target}`);
|
|
311
|
+
io.log(`计划清理: ${result.cleanup.oldReleases}`);
|
|
312
|
+
for (const verification of result.verification) {
|
|
313
|
+
io.log(`计划校验: ${verification}`);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
282
317
|
io.log(`已回滚到版本: ${result.version}`);
|
|
283
318
|
io.log(`当前指向: ${result.currentSymlink}`);
|
|
284
319
|
for (const warning of result.warnings) {
|
|
@@ -333,12 +368,16 @@ function printJsonProgress(command, event, io) {
|
|
|
333
368
|
...event,
|
|
334
369
|
}));
|
|
335
370
|
}
|
|
336
|
-
function printJsonError(command, error, io) {
|
|
337
|
-
|
|
371
|
+
function printJsonError(command, error, io, hint) {
|
|
372
|
+
const payload = {
|
|
338
373
|
ok: false,
|
|
339
374
|
command,
|
|
340
375
|
error,
|
|
341
|
-
}
|
|
376
|
+
};
|
|
377
|
+
if (hint) {
|
|
378
|
+
payload.hint = hint;
|
|
379
|
+
}
|
|
380
|
+
io.log(JSON.stringify(payload));
|
|
342
381
|
}
|
|
343
382
|
function toRealPath(filePath) {
|
|
344
383
|
try {
|
|
@@ -357,9 +396,12 @@ function createUsageText() {
|
|
|
357
396
|
ssh-release doctor [--config <path>]
|
|
358
397
|
ssh-release deploy [--config <path>]
|
|
359
398
|
ssh-release deploy --dry-run [--config <path>]
|
|
399
|
+
ssh-release deploy --plan [--config <path>]
|
|
360
400
|
ssh-release deploy --json --progress [--config <path>]
|
|
361
401
|
ssh-release list [--config <path>]
|
|
362
402
|
ssh-release rollback [version] [--config <path>]
|
|
403
|
+
ssh-release rollback [version] --dry-run [--config <path>]
|
|
404
|
+
ssh-release rollback [version] --plan [--config <path>]
|
|
363
405
|
ssh-release unlock [--confirm <lock-path>] [--config <path>]
|
|
364
406
|
ssh-release <command> --json
|
|
365
407
|
ssh-release --help
|
|
@@ -372,6 +414,38 @@ function readPackageVersion() {
|
|
|
372
414
|
function formatError(error) {
|
|
373
415
|
return error instanceof Error ? error.message : String(error);
|
|
374
416
|
}
|
|
417
|
+
function createErrorHint(command, error) {
|
|
418
|
+
if (error.includes('远程已有发布任务正在运行') || error.includes('.ssh-release.lock')) {
|
|
419
|
+
return '先运行 ssh-release unlock 查看远端锁,确认没有发布或回滚任务后再按提示删除锁。';
|
|
420
|
+
}
|
|
421
|
+
if (command === 'rollback') {
|
|
422
|
+
if (error.includes('回滚目标不存在')
|
|
423
|
+
|| error.includes('没有可回滚版本')
|
|
424
|
+
|| error.includes('当前版本不存在')) {
|
|
425
|
+
return '先运行 ssh-release list 查看当前版本和可用版本,再选择存在的版本回滚。';
|
|
426
|
+
}
|
|
427
|
+
if (error.includes('overwrite 模式不支持回滚')) {
|
|
428
|
+
return 'overwrite 模式没有版本目录;需要恢复内容时请重新发布正确的本地文件。';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (command === 'deploy' && (error.includes('current 未指向新版本')
|
|
432
|
+
|| error.includes('版本目录校验失败')
|
|
433
|
+
|| error.includes('目标目录校验失败')
|
|
434
|
+
|| error.includes('发布锁未清理')
|
|
435
|
+
|| error.includes('manifest.json hash'))) {
|
|
436
|
+
return '先运行 ssh-release list --json 和 ssh-release doctor --json,确认 current、版本目录、manifest 和远端锁状态。';
|
|
437
|
+
}
|
|
438
|
+
if (command
|
|
439
|
+
&& command !== 'init'
|
|
440
|
+
&& (error.includes('Permission denied')
|
|
441
|
+
|| error.includes('Connection timed out')
|
|
442
|
+
|| error.includes('Could not resolve hostname')
|
|
443
|
+
|| error.includes('sshpass')
|
|
444
|
+
|| error.includes('SSH'))) {
|
|
445
|
+
return '先运行 ssh-release doctor 检查 SSH 连接、认证信息和远端目录权限。';
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
375
449
|
if (isCliEntrypoint(import.meta.url, process.argv[1])) {
|
|
376
450
|
const exitCode = await runCli();
|
|
377
451
|
process.exit(exitCode);
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export async function createReleaseManifest(options) {
|
|
6
|
+
const sourceStat = await stat(options.sourcePath);
|
|
7
|
+
const files = sourceStat.isDirectory()
|
|
8
|
+
? await collectDirectoryFiles(options.sourcePath, options.exclude)
|
|
9
|
+
: [await createManifestFileEntry(options.sourcePath, path.basename(options.sourcePath))];
|
|
10
|
+
const sortedFiles = files.sort((left, right) => left.path.localeCompare(right.path));
|
|
11
|
+
return {
|
|
12
|
+
version: options.version,
|
|
13
|
+
createdAt: options.createdAt.toISOString(),
|
|
14
|
+
source: {
|
|
15
|
+
path: options.sourcePath,
|
|
16
|
+
type: sourceStat.isDirectory() ? 'directory' : 'file',
|
|
17
|
+
exclude: options.exclude,
|
|
18
|
+
},
|
|
19
|
+
files: sortedFiles,
|
|
20
|
+
totals: {
|
|
21
|
+
files: sortedFiles.length,
|
|
22
|
+
bytes: sortedFiles.reduce((total, file) => total + file.size, 0),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function writeReleaseManifest(manifest) {
|
|
27
|
+
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'ssh-release-manifest-'));
|
|
28
|
+
const localPath = path.join(tempDirectory, 'manifest.json');
|
|
29
|
+
const content = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
30
|
+
await writeFile(localPath, content);
|
|
31
|
+
return {
|
|
32
|
+
localPath,
|
|
33
|
+
sha256: createHash('sha256').update(content).digest('hex'),
|
|
34
|
+
cleanup: async () => {
|
|
35
|
+
await rm(tempDirectory, { recursive: true, force: true });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function collectDirectoryFiles(sourcePath, exclude, relativeDirectory = '') {
|
|
40
|
+
const entries = await readdir(path.join(sourcePath, relativeDirectory), { withFileTypes: true });
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
43
|
+
const relativePath = toPosixPath(path.join(relativeDirectory, entry.name));
|
|
44
|
+
if (isExcluded(relativePath, exclude)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const localPath = path.join(sourcePath, relativePath);
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
files.push(...await collectDirectoryFiles(sourcePath, exclude, relativePath));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (entry.isFile()) {
|
|
53
|
+
files.push(await createManifestFileEntry(localPath, relativePath));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
async function createManifestFileEntry(localPath, relativePath) {
|
|
59
|
+
const content = await readFile(localPath);
|
|
60
|
+
return {
|
|
61
|
+
path: toPosixPath(relativePath),
|
|
62
|
+
size: content.byteLength,
|
|
63
|
+
sha256: createHash('sha256').update(content).digest('hex'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isExcluded(relativePath, exclude) {
|
|
67
|
+
const normalizedPath = toPosixPath(relativePath);
|
|
68
|
+
const pathSegments = normalizedPath.split('/');
|
|
69
|
+
return exclude.some((pattern) => {
|
|
70
|
+
const normalizedPattern = toPosixPath(pattern);
|
|
71
|
+
if (!normalizedPattern.includes('/')) {
|
|
72
|
+
return pathSegments.includes(normalizedPattern);
|
|
73
|
+
}
|
|
74
|
+
return wildcardMatch(normalizedPath, normalizedPattern)
|
|
75
|
+
|| wildcardMatch(`./${normalizedPath}`, normalizedPattern);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function wildcardMatch(value, pattern) {
|
|
79
|
+
const escapedPattern = pattern
|
|
80
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
81
|
+
.replaceAll('*', '[^/]*');
|
|
82
|
+
return new RegExp(`^${escapedPattern}$`).test(value);
|
|
83
|
+
}
|
|
84
|
+
function toPosixPath(value) {
|
|
85
|
+
return value.split(path.sep).join(path.posix.sep);
|
|
86
|
+
}
|
package/dist/release.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { createReleasePackage, } from './package.js';
|
|
3
|
+
import { createReleaseManifest, writeReleaseManifest, } from './manifest.js';
|
|
3
4
|
import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
|
|
4
5
|
import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
5
6
|
export function createVersionName(date = new Date()) {
|
|
@@ -29,24 +30,46 @@ function pad(value) {
|
|
|
29
30
|
}
|
|
30
31
|
export async function deploy(config, client, options = {}) {
|
|
31
32
|
await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
|
|
32
|
-
const
|
|
33
|
+
const releaseDate = options.now ?? new Date();
|
|
34
|
+
const versionName = createVersionName(releaseDate);
|
|
33
35
|
const packageFactory = options.createPackage ?? createReleasePackage;
|
|
34
36
|
const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
|
|
35
37
|
let releasePackage;
|
|
38
|
+
let manifest;
|
|
39
|
+
let manifestFile;
|
|
36
40
|
let result;
|
|
37
41
|
let deployError;
|
|
38
42
|
let cleanupError;
|
|
39
43
|
try {
|
|
40
|
-
releasePackage = await withDeployProgress(options, 'package', () =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
releasePackage = await withDeployProgress(options, 'package', async () => {
|
|
45
|
+
const createdPackage = await packageFactory({
|
|
46
|
+
sourcePath: config.source.path,
|
|
47
|
+
exclude: config.source.exclude,
|
|
48
|
+
versionName,
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
manifest = await createReleaseManifest({
|
|
52
|
+
version: versionName,
|
|
53
|
+
createdAt: releaseDate,
|
|
54
|
+
sourcePath: config.source.path,
|
|
55
|
+
exclude: config.source.exclude,
|
|
56
|
+
});
|
|
57
|
+
manifestFile = await writeReleaseManifest(manifest);
|
|
58
|
+
return createdPackage;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await createdPackage.cleanup();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (!manifest || !manifestFile) {
|
|
66
|
+
throw new Error('发布清单生成失败');
|
|
67
|
+
}
|
|
45
68
|
if (config.deploy.mode === 'overwrite') {
|
|
46
|
-
result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, versionName));
|
|
69
|
+
result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, manifest, manifestFile, versionName));
|
|
47
70
|
return result;
|
|
48
71
|
}
|
|
49
|
-
result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, versionName));
|
|
72
|
+
result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, manifest, manifestFile, versionName));
|
|
50
73
|
return result;
|
|
51
74
|
}
|
|
52
75
|
catch (error) {
|
|
@@ -63,6 +86,14 @@ export async function deploy(config, client, options = {}) {
|
|
|
63
86
|
cleanupError = error;
|
|
64
87
|
}
|
|
65
88
|
}
|
|
89
|
+
if (manifestFile) {
|
|
90
|
+
try {
|
|
91
|
+
await manifestFile.cleanup();
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
cleanupError = error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
66
97
|
try {
|
|
67
98
|
await releaseLock();
|
|
68
99
|
}
|
|
@@ -87,35 +118,87 @@ export async function deploy(config, client, options = {}) {
|
|
|
87
118
|
}
|
|
88
119
|
export async function createDeployPlan(config, options = {}) {
|
|
89
120
|
await ensureSourceExists(config.source.path);
|
|
121
|
+
const releaseDate = options.now ?? new Date();
|
|
122
|
+
const versionName = createVersionName(releaseDate);
|
|
123
|
+
const tempArchivePath = remoteJoin(config.target.path, config.target.tempDir, `${versionName}.tgz`);
|
|
124
|
+
const lockPath = remoteJoin(config.target.path, '.ssh-release.lock');
|
|
125
|
+
const manifest = await createReleaseManifest({
|
|
126
|
+
version: versionName,
|
|
127
|
+
createdAt: releaseDate,
|
|
128
|
+
sourcePath: config.source.path,
|
|
129
|
+
exclude: config.source.exclude,
|
|
130
|
+
});
|
|
90
131
|
if (config.deploy.mode === 'overwrite') {
|
|
91
132
|
return {
|
|
92
133
|
dryRun: true,
|
|
93
134
|
mode: 'overwrite',
|
|
94
135
|
sourcePath: config.source.path,
|
|
95
136
|
targetPath: config.target.path,
|
|
137
|
+
upload: createDeployPlanUpload(config, manifest, tempArchivePath, remoteJoin(config.target.path, 'manifest.json')),
|
|
138
|
+
cleanup: {
|
|
139
|
+
lockPath,
|
|
140
|
+
tempArchivePath,
|
|
141
|
+
keepReleases: config.deploy.keepReleases,
|
|
142
|
+
oldReleases: 'overwrite 模式不清理版本目录',
|
|
143
|
+
},
|
|
144
|
+
verification: [
|
|
145
|
+
'目标目录存在',
|
|
146
|
+
'manifest.json hash 匹配',
|
|
147
|
+
'远端锁已清理',
|
|
148
|
+
],
|
|
96
149
|
};
|
|
97
150
|
}
|
|
98
|
-
const versionName = createVersionName(options.now ?? new Date());
|
|
99
151
|
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
152
|
+
const releasePath = remoteJoin(releasesPath, versionName);
|
|
153
|
+
const currentSymlink = remoteJoin(config.target.path, config.target.currentSymlink);
|
|
154
|
+
const switchTarget = remoteJoin(config.target.releasesDir, versionName);
|
|
100
155
|
return {
|
|
101
156
|
dryRun: true,
|
|
102
157
|
mode: 'release',
|
|
103
158
|
version: versionName,
|
|
104
159
|
sourcePath: config.source.path,
|
|
105
|
-
targetPath:
|
|
106
|
-
currentSymlink
|
|
160
|
+
targetPath: releasePath,
|
|
161
|
+
currentSymlink,
|
|
162
|
+
upload: createDeployPlanUpload(config, manifest, tempArchivePath, remoteJoin(releasePath, 'manifest.json')),
|
|
163
|
+
switch: {
|
|
164
|
+
currentSymlink,
|
|
165
|
+
target: switchTarget,
|
|
166
|
+
},
|
|
167
|
+
cleanup: {
|
|
168
|
+
lockPath,
|
|
169
|
+
tempArchivePath,
|
|
170
|
+
keepReleases: config.deploy.keepReleases,
|
|
171
|
+
oldReleases: `发布成功后保留最新 ${config.deploy.keepReleases} 个版本,并保留当前版本`,
|
|
172
|
+
},
|
|
173
|
+
verification: [
|
|
174
|
+
'版本目录存在',
|
|
175
|
+
'current 指向新版本',
|
|
176
|
+
'manifest.json hash 匹配',
|
|
177
|
+
'远端锁已清理',
|
|
178
|
+
],
|
|
107
179
|
};
|
|
108
180
|
}
|
|
109
|
-
|
|
181
|
+
function createDeployPlanUpload(config, manifest, archivePath, manifestPath) {
|
|
182
|
+
return {
|
|
183
|
+
sourcePath: config.source.path,
|
|
184
|
+
archivePath,
|
|
185
|
+
manifestPath,
|
|
186
|
+
fileCount: manifest.totals.files,
|
|
187
|
+
totalBytes: manifest.totals.bytes,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function deployRelease(config, client, releasePackage, manifest, manifestFile, versionName) {
|
|
110
191
|
const warnings = [];
|
|
111
192
|
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
112
193
|
const releasePath = remoteJoin(releasesPath, versionName);
|
|
113
194
|
const tempPath = remoteJoin(config.target.path, config.target.tempDir);
|
|
114
195
|
const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
|
|
196
|
+
const remoteManifestPath = remoteJoin(releasePath, 'manifest.json');
|
|
115
197
|
const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
|
|
116
198
|
await client.exec(`mkdir -p ${shellQuote(releasesPath)} ${shellQuote(tempPath)} ${shellQuote(releasePath)}`);
|
|
117
199
|
await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
|
|
118
200
|
const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, releasePath, true);
|
|
201
|
+
await client.uploadFile(manifestFile.localPath, remoteManifestPath);
|
|
119
202
|
await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, versionName))} ${shellQuote(currentSymlinkPath)}`);
|
|
120
203
|
await cleanupRemoteArchive(client, remoteArchivePath, warnings);
|
|
121
204
|
await cleanupOldReleases(config, client, releasesPath, versionName, warnings);
|
|
@@ -125,24 +208,36 @@ async function deployRelease(config, client, releasePackage, versionName) {
|
|
|
125
208
|
targetPath: releasePath,
|
|
126
209
|
currentSymlink: currentSymlinkPath,
|
|
127
210
|
usedFallback,
|
|
211
|
+
manifest: createDeployManifestSummary(manifest, manifestFile, remoteManifestPath),
|
|
128
212
|
warnings,
|
|
129
213
|
};
|
|
130
214
|
}
|
|
131
|
-
async function deployOverwrite(config, client, releasePackage, versionName) {
|
|
215
|
+
async function deployOverwrite(config, client, releasePackage, manifest, manifestFile, versionName) {
|
|
132
216
|
const warnings = [];
|
|
133
217
|
const tempPath = remoteJoin(config.target.path, config.target.tempDir);
|
|
134
218
|
const remoteArchivePath = remoteJoin(tempPath, `${versionName}.tgz`);
|
|
219
|
+
const remoteManifestPath = remoteJoin(config.target.path, 'manifest.json');
|
|
135
220
|
await client.exec(`mkdir -p ${shellQuote(config.target.path)} ${shellQuote(tempPath)}`);
|
|
136
221
|
await client.uploadFile(releasePackage.archivePath, remoteArchivePath);
|
|
137
222
|
const usedFallback = await publishPackageToRemotePath(config, client, remoteArchivePath, config.target.path, false);
|
|
223
|
+
await client.uploadFile(manifestFile.localPath, remoteManifestPath);
|
|
138
224
|
await cleanupRemoteArchive(client, remoteArchivePath, warnings);
|
|
139
225
|
return {
|
|
140
226
|
mode: 'overwrite',
|
|
141
227
|
targetPath: config.target.path,
|
|
142
228
|
usedFallback,
|
|
229
|
+
manifest: createDeployManifestSummary(manifest, manifestFile, remoteManifestPath),
|
|
143
230
|
warnings,
|
|
144
231
|
};
|
|
145
232
|
}
|
|
233
|
+
function createDeployManifestSummary(manifest, manifestFile, remotePath) {
|
|
234
|
+
return {
|
|
235
|
+
remotePath,
|
|
236
|
+
fileCount: manifest.totals.files,
|
|
237
|
+
totalBytes: manifest.totals.bytes,
|
|
238
|
+
sha256: manifestFile.sha256,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
146
241
|
async function publishPackageToRemotePath(config, client, remoteArchivePath, remoteTargetPath, cleanBeforeFallback) {
|
|
147
242
|
if (!config.deploy.preferTar) {
|
|
148
243
|
await client.uploadDirectory(config.source.path, remoteTargetPath, config.source.exclude);
|
|
@@ -190,6 +285,7 @@ async function verifyDeployResult(config, client, result) {
|
|
|
190
285
|
if (result.mode === 'overwrite') {
|
|
191
286
|
return [
|
|
192
287
|
await verifyRemoteDirectory(client, result.targetPath, '目标目录', '远端目标目录存在'),
|
|
288
|
+
...await verifyOptionalManifest(client, result),
|
|
193
289
|
await verifyRemoteLockReleased(config, client),
|
|
194
290
|
];
|
|
195
291
|
}
|
|
@@ -199,9 +295,42 @@ async function verifyDeployResult(config, client, result) {
|
|
|
199
295
|
return [
|
|
200
296
|
await verifyRemoteDirectory(client, result.targetPath, '版本目录', '远端版本目录存在'),
|
|
201
297
|
await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
|
|
298
|
+
...await verifyOptionalManifest(client, result),
|
|
202
299
|
await verifyRemoteLockReleased(config, client),
|
|
203
300
|
];
|
|
204
301
|
}
|
|
302
|
+
async function verifyOptionalManifest(client, result) {
|
|
303
|
+
if (!result.manifest) {
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
return [await verifyRemoteManifest(client, result.manifest)];
|
|
307
|
+
}
|
|
308
|
+
async function verifyRemoteManifest(client, manifest) {
|
|
309
|
+
try {
|
|
310
|
+
await client.exec(`test -f ${shellQuote(manifest.remotePath)}`);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
throw new Error(`manifest 校验失败: ${formatError(error)}`);
|
|
314
|
+
}
|
|
315
|
+
const actualHash = await readRemoteFileSha256(client, manifest.remotePath);
|
|
316
|
+
if (actualHash !== manifest.sha256) {
|
|
317
|
+
throw new Error(`manifest hash 不匹配: 期望 ${manifest.sha256},实际 ${actualHash || '空'}`);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
name: '发布清单',
|
|
321
|
+
status: 'pass',
|
|
322
|
+
message: `manifest.json 已上传并校验,文件数 ${manifest.fileCount}`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
async function readRemoteFileSha256(client, remotePath) {
|
|
326
|
+
const quotedPath = shellQuote(remotePath);
|
|
327
|
+
const result = await client.exec(`if command -v sha256sum >/dev/null 2>&1; then sha256sum ${quotedPath} | awk '{print $1}'; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${quotedPath} | awk '{print $1}'; else echo ''; fi`);
|
|
328
|
+
const hash = result.stdout.trim();
|
|
329
|
+
if (!hash) {
|
|
330
|
+
throw new Error('远端缺少 sha256sum 或 shasum,无法校验 manifest hash');
|
|
331
|
+
}
|
|
332
|
+
return hash;
|
|
333
|
+
}
|
|
205
334
|
async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
|
|
206
335
|
try {
|
|
207
336
|
await client.exec(`test -d ${shellQuote(remotePath)}`);
|
package/dist/rollback.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
2
|
-
import { acquireRemoteLock } from './lock.js';
|
|
2
|
+
import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
|
|
3
3
|
export function selectRollbackTarget(selection) {
|
|
4
4
|
const releases = [...new Set(selection.releases)].sort();
|
|
5
5
|
if (selection.requestedVersion) {
|
|
@@ -17,6 +17,50 @@ export function selectRollbackTarget(selection) {
|
|
|
17
17
|
}
|
|
18
18
|
return releases[currentIndex - 1];
|
|
19
19
|
}
|
|
20
|
+
export async function createRollbackPlan(config, client, requestedVersion) {
|
|
21
|
+
if (config.deploy.mode === 'overwrite') {
|
|
22
|
+
throw new Error('overwrite 模式不支持回滚');
|
|
23
|
+
}
|
|
24
|
+
const lockStatus = await readRemoteLockStatus(config, client);
|
|
25
|
+
if (lockStatus.locked) {
|
|
26
|
+
throw new Error(`远程已有发布任务正在运行,请确认后删除 ${lockStatus.lockPath}`);
|
|
27
|
+
}
|
|
28
|
+
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
29
|
+
const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
|
|
30
|
+
const releases = await readRemoteReleaseNames(client, releasesPath);
|
|
31
|
+
const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
|
|
32
|
+
if (!currentVersion) {
|
|
33
|
+
throw new Error('当前版本不存在');
|
|
34
|
+
}
|
|
35
|
+
const targetVersion = selectRollbackTarget({
|
|
36
|
+
releases,
|
|
37
|
+
currentVersion,
|
|
38
|
+
requestedVersion,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
dryRun: true,
|
|
42
|
+
mode: 'release',
|
|
43
|
+
version: targetVersion,
|
|
44
|
+
requestedVersion,
|
|
45
|
+
currentVersion,
|
|
46
|
+
targetPath: remoteJoin(releasesPath, targetVersion),
|
|
47
|
+
currentSymlink: currentSymlinkPath,
|
|
48
|
+
switch: {
|
|
49
|
+
currentSymlink: currentSymlinkPath,
|
|
50
|
+
from: remoteJoin(config.target.releasesDir, currentVersion),
|
|
51
|
+
target: remoteJoin(config.target.releasesDir, targetVersion),
|
|
52
|
+
},
|
|
53
|
+
cleanup: {
|
|
54
|
+
lockPath: lockStatus.lockPath,
|
|
55
|
+
oldReleases: '回滚只切换 current,不删除任何版本目录',
|
|
56
|
+
},
|
|
57
|
+
verification: [
|
|
58
|
+
'远端锁未占用',
|
|
59
|
+
'目标版本目录存在',
|
|
60
|
+
'current 将指向目标版本',
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
20
64
|
export async function rollback(config, client, requestedVersion) {
|
|
21
65
|
if (config.deploy.mode === 'overwrite') {
|
|
22
66
|
throw new Error('overwrite 模式不支持回滚');
|