ssh-release 0.5.0 → 0.7.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 +12 -0
- package/README.md +28 -1
- package/dist/cli.js +39 -3
- package/dist/release.js +107 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增加发布后远端状态校验,`deploy` 成功后会确认目标目录、`current` 指向和远端锁清理状态,并在结果中输出 `verified` 与 `verification`。
|
|
8
|
+
|
|
9
|
+
## 0.6.0 - 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 增加 `ssh-release deploy --json --progress`,发布时按 NDJSON 输出 `source`、`lock`、`package`、`publish`、`cleanup` 阶段状态,并在最后输出发布结果。
|
|
14
|
+
|
|
3
15
|
## 0.5.0 - 2026-06-25
|
|
4
16
|
|
|
5
17
|
### Added
|
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
- `ssh-release rollback [version]`:回滚到上一个版本或指定版本。
|
|
18
18
|
- `ssh-release unlock`:查看远端锁,并在显式确认锁路径后删除锁。
|
|
19
19
|
- `--json`:输出单行 JSON,便于 CI/CD 解析。
|
|
20
|
+
- `deploy --json --progress`:发布时输出 NDJSON 阶段进度,便于 CI/CD 展示实时状态。
|
|
21
|
+
- 发布后远端校验:确认版本目录或目标目录存在、`current` 已指向新版本、远端锁已清理。
|
|
20
22
|
- `release` 模式:上传压缩包、远端解压、切换 `current`、清理旧版本。
|
|
21
23
|
- `overwrite` 模式:直接覆盖发布到目标目录。
|
|
22
24
|
- 远端 `tar` 解压失败时回退逐文件上传。
|
|
@@ -187,6 +189,8 @@ ssh-release deploy
|
|
|
187
189
|
|
|
188
190
|
`release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
|
|
189
191
|
|
|
192
|
+
发布命令返回成功前会执行远端状态校验。`release` 模式会确认新版本目录存在、`current` 已指向新版本、远端锁已清理;`overwrite` 模式会确认目标目录存在、远端锁已清理。校验失败时发布命令返回失败,不会把结果标记为成功。
|
|
193
|
+
|
|
190
194
|
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。
|
|
191
195
|
|
|
192
196
|
查看锁状态:
|
|
@@ -231,6 +235,7 @@ ssh-release rollback 20260625-150000
|
|
|
231
235
|
|
|
232
236
|
```bash
|
|
233
237
|
ssh-release deploy --dry-run --json
|
|
238
|
+
ssh-release deploy --json --progress
|
|
234
239
|
ssh-release doctor --json
|
|
235
240
|
ssh-release list --json
|
|
236
241
|
ssh-release rollback --json
|
|
@@ -240,7 +245,7 @@ ssh-release unlock --json
|
|
|
240
245
|
成功时输出单行 JSON:
|
|
241
246
|
|
|
242
247
|
```json
|
|
243
|
-
{"ok":true,"command":"deploy","result":{"
|
|
248
|
+
{"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
|
|
244
249
|
```
|
|
245
250
|
|
|
246
251
|
命令执行失败时输出:
|
|
@@ -249,6 +254,28 @@ ssh-release unlock --json
|
|
|
249
254
|
{"ok":false,"command":"deploy","error":"错误信息"}
|
|
250
255
|
```
|
|
251
256
|
|
|
257
|
+
发布时需要持续读取阶段状态,可以使用:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
ssh-release deploy --json --progress
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
该模式会按行输出 NDJSON。进度事件先输出,最终结果最后输出:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{"ok":true,"command":"deploy","event":"progress","stage":"package","status":"start"}
|
|
267
|
+
{"ok":true,"command":"deploy","event":"progress","stage":"package","status":"success"}
|
|
268
|
+
{"ok":true,"command":"deploy","result":{"mode":"release","version":"20260625-153000","verified":true}}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`stage` 可能是 `source`、`lock`、`package`、`publish`、`cleanup`,`status` 可能是 `start`、`success`、`fail`。失败事件会包含 `error` 字段。
|
|
272
|
+
|
|
273
|
+
发布结果中的 `verification` 会列出已通过的远端校验项:
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
{"verified":true,"verification":[{"name":"当前版本","status":"pass","message":"current 已指向新版本"}]}
|
|
277
|
+
```
|
|
278
|
+
|
|
252
279
|
`doctor` 检查失败、`unlock` 发现锁但未删除时会返回非零退出码,并在 JSON 的 `result` 中保留检查结果。
|
|
253
280
|
|
|
254
281
|
## 安全边界
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { CONFIG_FILE_NAME, loadConfigFile, writeConfigTemplate } from './config.js';
|
|
6
6
|
import { runDoctorFromFile } from './doctor.js';
|
|
7
7
|
import { listReleases } from './list.js';
|
|
8
|
-
import { createDeployPlan, deploy } from './release.js';
|
|
8
|
+
import { createDeployPlan, deploy, } from './release.js';
|
|
9
9
|
import { rollback } from './rollback.js';
|
|
10
10
|
import { createRemoteClient } from './ssh.js';
|
|
11
11
|
import { unlock } from './unlock.js';
|
|
@@ -57,7 +57,13 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
57
57
|
return 0;
|
|
58
58
|
}
|
|
59
59
|
if (command === 'deploy') {
|
|
60
|
-
const
|
|
60
|
+
const deployOptions = {
|
|
61
|
+
dryRun: parsed.dryRun,
|
|
62
|
+
};
|
|
63
|
+
if (parsed.json && parsed.progress) {
|
|
64
|
+
deployOptions.onProgress = (event) => printJsonProgress('deploy', event, io);
|
|
65
|
+
}
|
|
66
|
+
const result = await handlers.deploy(deployOptions);
|
|
61
67
|
if (parsed.json) {
|
|
62
68
|
printJsonResult('deploy', result, 0, io);
|
|
63
69
|
return 0;
|
|
@@ -130,6 +136,7 @@ function parseCliArgs(argv) {
|
|
|
130
136
|
let dryRun = false;
|
|
131
137
|
let help = false;
|
|
132
138
|
let json = false;
|
|
139
|
+
let progress = false;
|
|
133
140
|
let version = false;
|
|
134
141
|
for (let index = 0; index < argv.length; index += 1) {
|
|
135
142
|
const arg = argv[index];
|
|
@@ -145,6 +152,10 @@ function parseCliArgs(argv) {
|
|
|
145
152
|
json = true;
|
|
146
153
|
continue;
|
|
147
154
|
}
|
|
155
|
+
if (arg === '--progress') {
|
|
156
|
+
progress = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
148
159
|
if (arg === '--config' || arg === '-c') {
|
|
149
160
|
const value = argv[index + 1];
|
|
150
161
|
if (!value || value.startsWith('-')) {
|
|
@@ -172,6 +183,12 @@ function parseCliArgs(argv) {
|
|
|
172
183
|
}
|
|
173
184
|
args.push(arg);
|
|
174
185
|
}
|
|
186
|
+
if (progress && !json) {
|
|
187
|
+
return createParsedError('--progress 需要配合 --json 使用', configPath, json);
|
|
188
|
+
}
|
|
189
|
+
if (progress && args[0] && args[0] !== 'deploy') {
|
|
190
|
+
return createParsedError('--progress 仅支持 deploy 命令', configPath, json);
|
|
191
|
+
}
|
|
175
192
|
return {
|
|
176
193
|
args: args.slice(1),
|
|
177
194
|
command: args[0],
|
|
@@ -180,6 +197,7 @@ function parseCliArgs(argv) {
|
|
|
180
197
|
dryRun,
|
|
181
198
|
help,
|
|
182
199
|
json,
|
|
200
|
+
progress,
|
|
183
201
|
version,
|
|
184
202
|
};
|
|
185
203
|
}
|
|
@@ -191,6 +209,7 @@ function createParsedError(error, configPath, json = false) {
|
|
|
191
209
|
error,
|
|
192
210
|
help: false,
|
|
193
211
|
json,
|
|
212
|
+
progress: false,
|
|
194
213
|
version: false,
|
|
195
214
|
};
|
|
196
215
|
}
|
|
@@ -202,7 +221,9 @@ function createDefaultHandlers(configPath) {
|
|
|
202
221
|
if (options?.dryRun) {
|
|
203
222
|
return createDeployPlan(config);
|
|
204
223
|
}
|
|
205
|
-
return deploy(config, createRemoteClient(config)
|
|
224
|
+
return deploy(config, createRemoteClient(config), {
|
|
225
|
+
onProgress: options?.onProgress,
|
|
226
|
+
});
|
|
206
227
|
},
|
|
207
228
|
rollback: async (version) => {
|
|
208
229
|
const config = await loadConfigFile(configPath);
|
|
@@ -247,6 +268,12 @@ function printDeployResult(result, io) {
|
|
|
247
268
|
if (result.usedFallback) {
|
|
248
269
|
io.log('远端 tar 不可用或解压失败,已使用逐文件上传');
|
|
249
270
|
}
|
|
271
|
+
if (result.verified) {
|
|
272
|
+
io.log('远端校验通过');
|
|
273
|
+
for (const check of result.verification ?? []) {
|
|
274
|
+
io.log(`校验: ${check.name} - ${check.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
250
277
|
for (const warning of result.warnings) {
|
|
251
278
|
io.log(`警告: ${warning}`);
|
|
252
279
|
}
|
|
@@ -298,6 +325,14 @@ function printJsonResult(command, result, exitCode, io) {
|
|
|
298
325
|
result,
|
|
299
326
|
}));
|
|
300
327
|
}
|
|
328
|
+
function printJsonProgress(command, event, io) {
|
|
329
|
+
io.log(JSON.stringify({
|
|
330
|
+
ok: true,
|
|
331
|
+
command,
|
|
332
|
+
event: 'progress',
|
|
333
|
+
...event,
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
301
336
|
function printJsonError(command, error, io) {
|
|
302
337
|
io.log(JSON.stringify({
|
|
303
338
|
ok: false,
|
|
@@ -322,6 +357,7 @@ function createUsageText() {
|
|
|
322
357
|
ssh-release doctor [--config <path>]
|
|
323
358
|
ssh-release deploy [--config <path>]
|
|
324
359
|
ssh-release deploy --dry-run [--config <path>]
|
|
360
|
+
ssh-release deploy --json --progress [--config <path>]
|
|
325
361
|
ssh-release list [--config <path>]
|
|
326
362
|
ssh-release rollback [version] [--config <path>]
|
|
327
363
|
ssh-release unlock [--confirm <lock-path>] [--config <path>]
|
package/dist/release.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { createReleasePackage, } from './package.js';
|
|
3
|
-
import { acquireRemoteLock } from './lock.js';
|
|
3
|
+
import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
|
|
4
4
|
import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
5
5
|
export function createVersionName(date = new Date()) {
|
|
6
6
|
return [
|
|
@@ -28,25 +28,25 @@ function pad(value) {
|
|
|
28
28
|
return value.toString().padStart(2, '0');
|
|
29
29
|
}
|
|
30
30
|
export async function deploy(config, client, options = {}) {
|
|
31
|
-
await ensureSourceExists(config.source.path);
|
|
31
|
+
await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
|
|
32
32
|
const versionName = createVersionName(options.now ?? new Date());
|
|
33
33
|
const packageFactory = options.createPackage ?? createReleasePackage;
|
|
34
|
-
const releaseLock = await acquireRemoteLock(config, client);
|
|
34
|
+
const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
|
|
35
35
|
let releasePackage;
|
|
36
36
|
let result;
|
|
37
37
|
let deployError;
|
|
38
38
|
let cleanupError;
|
|
39
39
|
try {
|
|
40
|
-
releasePackage = await packageFactory({
|
|
40
|
+
releasePackage = await withDeployProgress(options, 'package', () => packageFactory({
|
|
41
41
|
sourcePath: config.source.path,
|
|
42
42
|
exclude: config.source.exclude,
|
|
43
43
|
versionName,
|
|
44
|
-
});
|
|
44
|
+
}));
|
|
45
45
|
if (config.deploy.mode === 'overwrite') {
|
|
46
|
-
result = await deployOverwrite(config, client, releasePackage, versionName);
|
|
46
|
+
result = await withDeployProgress(options, 'publish', () => deployOverwrite(config, client, releasePackage, versionName));
|
|
47
47
|
return result;
|
|
48
48
|
}
|
|
49
|
-
result = await deployRelease(config, client, releasePackage, versionName);
|
|
49
|
+
result = await withDeployProgress(options, 'publish', () => deployRelease(config, client, releasePackage, versionName));
|
|
50
50
|
return result;
|
|
51
51
|
}
|
|
52
52
|
catch (error) {
|
|
@@ -54,29 +54,35 @@ export async function deploy(config, client, options = {}) {
|
|
|
54
54
|
throw error;
|
|
55
55
|
}
|
|
56
56
|
finally {
|
|
57
|
-
|
|
57
|
+
await withDeployProgress(options, 'cleanup', async () => {
|
|
58
|
+
if (releasePackage) {
|
|
59
|
+
try {
|
|
60
|
+
await releasePackage.cleanup();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
cleanupError = error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
58
66
|
try {
|
|
59
|
-
await
|
|
67
|
+
await releaseLock();
|
|
60
68
|
}
|
|
61
69
|
catch (error) {
|
|
62
|
-
|
|
70
|
+
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
71
|
+
if (result) {
|
|
72
|
+
result.warnings.push(message);
|
|
73
|
+
}
|
|
74
|
+
else if (!deployError && !cleanupError) {
|
|
75
|
+
cleanupError = error;
|
|
76
|
+
}
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
70
|
-
if (result) {
|
|
71
|
-
result.warnings.push(message);
|
|
78
|
+
if (result && !deployError && !cleanupError) {
|
|
79
|
+
result.verification = await verifyDeployResult(config, client, result);
|
|
80
|
+
result.verified = true;
|
|
72
81
|
}
|
|
73
|
-
|
|
74
|
-
cleanupError
|
|
82
|
+
if (cleanupError && !deployError) {
|
|
83
|
+
throw cleanupError;
|
|
75
84
|
}
|
|
76
|
-
}
|
|
77
|
-
if (cleanupError && !deployError) {
|
|
78
|
-
throw cleanupError;
|
|
79
|
-
}
|
|
85
|
+
});
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
export async function createDeployPlan(config, options = {}) {
|
|
@@ -180,6 +186,64 @@ async function cleanupOldReleases(config, client, releasesPath, currentVersion,
|
|
|
180
186
|
warnings.push(`旧版本清理失败: ${formatError(error)}`);
|
|
181
187
|
}
|
|
182
188
|
}
|
|
189
|
+
async function verifyDeployResult(config, client, result) {
|
|
190
|
+
if (result.mode === 'overwrite') {
|
|
191
|
+
return [
|
|
192
|
+
await verifyRemoteDirectory(client, result.targetPath, '目标目录', '远端目标目录存在'),
|
|
193
|
+
await verifyRemoteLockReleased(config, client),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
if (!result.version || !result.currentSymlink) {
|
|
197
|
+
throw new Error('release 发布结果缺少版本号或 current 路径,无法校验远端状态');
|
|
198
|
+
}
|
|
199
|
+
return [
|
|
200
|
+
await verifyRemoteDirectory(client, result.targetPath, '版本目录', '远端版本目录存在'),
|
|
201
|
+
await verifyCurrentSymlink(client, result.currentSymlink, remoteJoin(config.target.releasesDir, result.version)),
|
|
202
|
+
await verifyRemoteLockReleased(config, client),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
async function verifyRemoteDirectory(client, remotePath, name, successMessage) {
|
|
206
|
+
try {
|
|
207
|
+
await client.exec(`test -d ${shellQuote(remotePath)}`);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw new Error(`${name}校验失败: ${formatError(error)}`);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
name,
|
|
214
|
+
status: 'pass',
|
|
215
|
+
message: successMessage,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function verifyCurrentSymlink(client, currentSymlinkPath, expectedTarget) {
|
|
219
|
+
let actualTarget;
|
|
220
|
+
try {
|
|
221
|
+
const result = await client.exec(`readlink ${shellQuote(currentSymlinkPath)}`);
|
|
222
|
+
actualTarget = result.stdout.trim();
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw new Error(`current 校验失败: ${formatError(error)}`);
|
|
226
|
+
}
|
|
227
|
+
if (actualTarget !== expectedTarget) {
|
|
228
|
+
throw new Error(`current 未指向新版本: 期望 ${expectedTarget},实际 ${actualTarget || '空'}`);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
name: '当前版本',
|
|
232
|
+
status: 'pass',
|
|
233
|
+
message: 'current 已指向新版本',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async function verifyRemoteLockReleased(config, client) {
|
|
237
|
+
const lockStatus = await readRemoteLockStatus(config, client);
|
|
238
|
+
if (lockStatus.locked) {
|
|
239
|
+
throw new Error(`发布锁未清理: ${lockStatus.lockPath}`);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
name: '远端锁',
|
|
243
|
+
status: 'pass',
|
|
244
|
+
message: '发布锁已清理',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
183
247
|
async function ensureSourceExists(sourcePath) {
|
|
184
248
|
try {
|
|
185
249
|
await access(sourcePath);
|
|
@@ -191,3 +255,22 @@ async function ensureSourceExists(sourcePath) {
|
|
|
191
255
|
function formatError(error) {
|
|
192
256
|
return error instanceof Error ? error.message : String(error);
|
|
193
257
|
}
|
|
258
|
+
async function withDeployProgress(options, stage, run) {
|
|
259
|
+
await emitDeployProgress(options, { stage, status: 'start' });
|
|
260
|
+
try {
|
|
261
|
+
const result = await run();
|
|
262
|
+
await emitDeployProgress(options, { stage, status: 'success' });
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
await emitDeployProgress(options, {
|
|
267
|
+
stage,
|
|
268
|
+
status: 'fail',
|
|
269
|
+
error: formatError(error),
|
|
270
|
+
});
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function emitDeployProgress(options, event) {
|
|
275
|
+
await options.onProgress?.(event);
|
|
276
|
+
}
|