ssh-release 1.2.0 → 1.3.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-06-26
4
+
5
+ ### Added
6
+
7
+ - 增加 `ssh-release rollback --json --progress`,回滚时按 NDJSON 输出 `lock`、`switch`、`cleanup`、`verify` 阶段状态。
8
+ - 增强回滚后远端校验,成功结果会输出 `verified` 与 `verification`,确认目标版本目录、`current` 指向和远端锁清理状态。
9
+
3
10
  ## 1.2.0 - 2026-06-26
4
11
 
5
12
  ### Added
package/README.md CHANGED
@@ -18,11 +18,13 @@
18
18
  - `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
19
19
  - `--json`:输出单行 JSON,便于 CI/CD 解析。
20
20
  - `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
+ - `rollback --json --progress`:回滚时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
21
22
  - `deploy --plan`:不连接远端、不修改服务器,预览上传、切换、清理和校验计划。
22
23
  - `rollback --plan`:连接远端读取版本状态,但不修改服务器,预览回滚切换计划。
23
24
  - 失败下一步提示:常见锁、回滚目标、发布校验和 SSH 错误会提示下一步操作。
24
25
  - 发布 manifest:每次发布生成 `manifest.json`,记录版本、发布时间、本地来源、文件清单、文件大小和 SHA-256。
25
26
  - 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、`manifest.json` hash 匹配、远端锁已清理。
27
+ - 回滚后远端校验:确认目标版本目录存在、`current` 已指向目标版本、远端锁已清理。
26
28
  - `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
27
29
  - `overwrite` 模式:直接覆盖发布到目标目录。
28
30
  - 远端 `tar` 解压失败时回退逐文件上传。
@@ -285,6 +287,7 @@ ssh-release deploy --json --progress
285
287
  ssh-release doctor --json
286
288
  ssh-release list --json
287
289
  ssh-release rollback --json
290
+ ssh-release rollback --json --progress
288
291
  ssh-release unlock --json
289
292
  ```
290
293
 
@@ -300,10 +303,11 @@ ssh-release unlock --json
300
303
  {"ok":false,"command":"deploy","error":"错误信息"}
301
304
  ```
302
305
 
303
- 发布时需要持续读取阶段状态,可以使用:
306
+ 发布或回滚时需要持续读取阶段状态,可以使用:
304
307
 
305
308
  ```bash
306
309
  ssh-release deploy --json --progress
310
+ ssh-release rollback --json --progress
307
311
  ```
308
312
 
309
313
  该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
@@ -314,9 +318,11 @@ ssh-release deploy --json --progress
314
318
  {"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
315
319
  ```
316
320
 
317
- `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
321
+ `deploy` 的 `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`。
322
+ `rollback` 的 `stage` 可能是 `lock`、`switch`、`cleanup`、`verify`。
323
+ `status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
318
324
 
319
- 发布结果中的 `verification` 会列出已通过的远端校验项:
325
+ 发布和回滚结果中的 `verification` 会列出已通过的远端校验项:
320
326
 
321
327
  ```json
322
328
  {"verified":true,"verification":[{"name":"发布清单","status":"pass","message":"manifest.json 已上传并校验,文件数 12"}]}
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 { createRollbackPlan, 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) {
@@ -73,7 +73,13 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
73
73
  return 0;
74
74
  }
75
75
  if (command === 'rollback') {
76
- const result = await handlers.rollback(args[0], { dryRun: parsed.dryRun });
76
+ const rollbackOptions = {
77
+ dryRun: parsed.dryRun,
78
+ };
79
+ if (parsed.json && parsed.progress) {
80
+ rollbackOptions.onProgress = (event) => printJsonProgress('rollback', event, io);
81
+ }
82
+ const result = await handlers.rollback(args[0], rollbackOptions);
77
83
  if (parsed.json) {
78
84
  printJsonResult('rollback', result, 0, io);
79
85
  return 0;
@@ -192,8 +198,8 @@ function parseCliArgs(argv) {
192
198
  if (progress && !json) {
193
199
  return createParsedError('--progress 需要配合 --json 使用', configPath, json);
194
200
  }
195
- if (progress && args[0] && args[0] !== 'deploy') {
196
- return createParsedError('--progress 仅支持 deploy 命令', configPath, json);
201
+ if (progress && args[0] && args[0] !== 'deploy' && args[0] !== 'rollback') {
202
+ return createParsedError('--progress 仅支持 deploy 或 rollback 命令', configPath, json);
197
203
  }
198
204
  return {
199
205
  args: args.slice(1),
@@ -236,7 +242,9 @@ function createDefaultHandlers(configPath) {
236
242
  if (options?.dryRun) {
237
243
  return createRollbackPlan(config, createRemoteClient(config), version);
238
244
  }
239
- return rollback(config, createRemoteClient(config), version);
245
+ return rollback(config, createRemoteClient(config), version, {
246
+ onProgress: options?.onProgress,
247
+ });
240
248
  },
241
249
  list: async () => {
242
250
  const config = await loadConfigFile(configPath);
@@ -317,6 +325,12 @@ function printRollbackResult(result, io) {
317
325
  }
318
326
  io.log(`已回滚到版本: ${result.version}`);
319
327
  io.log(`当前指向: ${result.currentSymlink}`);
328
+ if (result.verified) {
329
+ io.log('回滚校验通过');
330
+ for (const check of result.verification ?? []) {
331
+ io.log(`校验: ${check.name} - ${check.message}`);
332
+ }
333
+ }
320
334
  for (const warning of result.warnings) {
321
335
  io.log(`警告: ${warning}`);
322
336
  }
@@ -410,6 +424,7 @@ function createUsageText() {
410
424
  ssh-release rollback [version] [--config <path>]
411
425
  ssh-release rollback [version] --dry-run [--config <path>]
412
426
  ssh-release rollback [version] --plan [--config <path>]
427
+ ssh-release rollback [version] --json --progress [--config <path>]
413
428
  ssh-release unlock [--confirm <lock-path>] [--config <path>]
414
429
  ssh-release <command> --json
415
430
  ssh-release --help
package/dist/rollback.js CHANGED
@@ -61,32 +61,35 @@ export async function createRollbackPlan(config, client, requestedVersion) {
61
61
  ],
62
62
  };
63
63
  }
64
- export async function rollback(config, client, requestedVersion) {
64
+ export async function rollback(config, client, requestedVersion, options = {}) {
65
65
  if (config.deploy.mode === 'overwrite') {
66
66
  throw new Error('overwrite 模式不支持回滚');
67
67
  }
68
- const releaseLock = await acquireRemoteLock(config, client, { createTargetPath: false });
68
+ const releaseLock = await withRollbackProgress(options, 'lock', () => acquireRemoteLock(config, client, { createTargetPath: false }));
69
69
  let result;
70
70
  let rollbackError;
71
+ let cleanupError;
71
72
  try {
72
- const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
73
- const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
74
- const releases = await readRemoteReleaseNames(client, releasesPath);
75
- const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
76
- if (!currentVersion) {
77
- throw new Error('当前版本不存在');
78
- }
79
- const targetVersion = selectRollbackTarget({
80
- releases,
81
- currentVersion,
82
- requestedVersion,
73
+ result = await withRollbackProgress(options, 'switch', async () => {
74
+ const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
75
+ const currentSymlinkPath = remoteJoin(config.target.path, config.target.currentSymlink);
76
+ const releases = await readRemoteReleaseNames(client, releasesPath);
77
+ const currentVersion = await readCurrentVersion(client, currentSymlinkPath);
78
+ if (!currentVersion) {
79
+ throw new Error('当前版本不存在');
80
+ }
81
+ const targetVersion = selectRollbackTarget({
82
+ releases,
83
+ currentVersion,
84
+ requestedVersion,
85
+ });
86
+ await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
87
+ return {
88
+ version: targetVersion,
89
+ currentSymlink: currentSymlinkPath,
90
+ warnings: [],
91
+ };
83
92
  });
84
- await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
85
- result = {
86
- version: targetVersion,
87
- currentSymlink: currentSymlinkPath,
88
- warnings: [],
89
- };
90
93
  return result;
91
94
  }
92
95
  catch (error) {
@@ -94,20 +97,100 @@ export async function rollback(config, client, requestedVersion) {
94
97
  throw error;
95
98
  }
96
99
  finally {
97
- try {
98
- await releaseLock();
99
- }
100
- catch (error) {
101
- const message = `远程发布锁清理失败: ${formatError(error)}`;
102
- if (result) {
103
- result.warnings.push(message);
100
+ await withRollbackProgress(options, 'cleanup', async () => {
101
+ try {
102
+ await releaseLock();
103
+ }
104
+ catch (error) {
105
+ const message = `远程回滚锁清理失败: ${formatError(error)}`;
106
+ if (result) {
107
+ result.warnings.push(message);
108
+ }
109
+ else if (!rollbackError) {
110
+ cleanupError = error;
111
+ }
104
112
  }
105
- else if (!rollbackError) {
106
- throw error;
113
+ if (cleanupError && !rollbackError) {
114
+ throw cleanupError;
107
115
  }
116
+ });
117
+ const rollbackResult = result;
118
+ if (rollbackResult && !rollbackError && !cleanupError) {
119
+ await withRollbackProgress(options, 'verify', async () => {
120
+ rollbackResult.verification = await verifyRollbackResult(config, client, rollbackResult);
121
+ rollbackResult.verified = true;
122
+ });
108
123
  }
109
124
  }
110
125
  }
126
+ async function verifyRollbackResult(config, client, result) {
127
+ return [
128
+ await verifyRemoteDirectory(client, remoteJoin(config.target.path, config.target.releasesDir, result.version), '目标版本', '目标版本目录存在'),
129
+ await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
130
+ await verifyRemoteLockReleased(config, client),
131
+ ];
132
+ }
133
+ async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
134
+ try {
135
+ await client.exec(`test -d ${shellQuote(remotePath)}`);
136
+ }
137
+ catch (error) {
138
+ throw new Error(`${name}校验失败: ${formatError(error)}`);
139
+ }
140
+ return {
141
+ name,
142
+ status: 'pass',
143
+ message: successMessage,
144
+ };
145
+ }
146
+ async function verifyCurrentSymlink(client, currentSymlinkPath, expectedTarget) {
147
+ let actualTarget;
148
+ try {
149
+ const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)}`);
150
+ actualTarget = result.stdout.trim();
151
+ }
152
+ catch (error) {
153
+ throw new Error(`current 校验失败: ${formatError(error)}`);
154
+ }
155
+ if (actualTarget !== expectedTarget) {
156
+ throw new Error(`current 未指向目标版本: 期望 ${expectedTarget},实际 ${actualTarget || '空'}`);
157
+ }
158
+ return {
159
+ name: '当前版本',
160
+ status: 'pass',
161
+ message: 'current 已指向目标版本',
162
+ };
163
+ }
164
+ async function verifyRemoteLockReleased(config, client) {
165
+ const lockStatus = await readRemoteLockStatus(config, client);
166
+ if (lockStatus.locked) {
167
+ throw new Error(`回滚锁未清理: ${lockStatus.lockPath}`);
168
+ }
169
+ return {
170
+ name: '远端锁',
171
+ status: 'pass',
172
+ message: '回滚锁已清理',
173
+ };
174
+ }
175
+ async function withRollbackProgress(options, stage, run) {
176
+ await emitRollbackProgress(options, { stage, status: 'start' });
177
+ try {
178
+ const result = await run();
179
+ await emitRollbackProgress(options, { stage, status: 'success' });
180
+ return result;
181
+ }
182
+ catch (error) {
183
+ await emitRollbackProgress(options, {
184
+ stage,
185
+ status: 'fail',
186
+ error: formatError(error),
187
+ });
188
+ throw error;
189
+ }
190
+ }
191
+ async function emitRollbackProgress(options, event) {
192
+ await options.onProgress?.(event);
193
+ }
111
194
  function formatError(error) {
112
195
  return error instanceof Error ? error.message : String(error);
113
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {