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 +7 -0
- package/README.md +9 -3
- package/dist/cli.js +20 -5
- package/dist/rollback.js +111 -28
- package/package.json +1 -1
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
|
|
321
|
+
`deploy` 的 `stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`。
|
|
322
|
+
`rollback` 的 `stage` 可能是 `lock`、`switch`、`cleanup`、`verify`。
|
|
323
|
+
`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
|
|
318
324
|
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
result
|
|
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
|
-
|
|
106
|
-
throw
|
|
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
|
}
|