ssh-release 0.1.0 → 0.2.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 +10 -0
- package/README.md +31 -0
- package/dist/cli.js +115 -19
- package/dist/lock.js +21 -0
- package/dist/release.js +63 -8
- package/dist/rollback.js +45 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-06-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 增加 `--help` 和 `--version`,方便安装后确认 CLI 能力和版本。
|
|
8
|
+
- 增加 `--config <path>`,支持使用非默认配置文件。
|
|
9
|
+
- 增加 `ssh-release deploy --dry-run`,可在不连接远端、不修改服务器的情况下查看发布计划。
|
|
10
|
+
- 增加远端锁 `.ssh-release.lock`,避免同一目标目录并发发布或回滚。
|
|
11
|
+
- 增加 GitHub Actions CI,在 `main` 推送和 Pull Request 上运行安装、类型检查、测试和构建。
|
|
12
|
+
|
|
3
13
|
## 0.1.0 - 2026-06-25
|
|
4
14
|
|
|
5
15
|
首个 npm 版本。
|
package/README.md
CHANGED
|
@@ -40,6 +40,13 @@ npm install -g ssh-release
|
|
|
40
40
|
ssh-release init
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
查看帮助和版本:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ssh-release --help
|
|
47
|
+
ssh-release --version
|
|
48
|
+
```
|
|
49
|
+
|
|
43
50
|
## 本地开发
|
|
44
51
|
|
|
45
52
|
```bash
|
|
@@ -78,6 +85,14 @@ npm run dev -- init
|
|
|
78
85
|
|
|
79
86
|
生成的配置文件名为 `ssh-release.config.ts`。
|
|
80
87
|
|
|
88
|
+
需要使用自定义配置路径时,可以传入 `--config`:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ssh-release init --config deploy.config.ts
|
|
92
|
+
ssh-release doctor --config deploy.config.ts
|
|
93
|
+
ssh-release deploy --config deploy.config.ts
|
|
94
|
+
```
|
|
95
|
+
|
|
81
96
|
配置文件应使用 `export default` 导出配置对象。当前模板支持从环境变量读取服务器地址和用户名:
|
|
82
97
|
|
|
83
98
|
示例配置:
|
|
@@ -130,11 +145,14 @@ source files -> package -> upload -> release -> activate -> rollback
|
|
|
130
145
|
├── releases/
|
|
131
146
|
│ ├── 20260625-153000/
|
|
132
147
|
│ └── 20260625-150000/
|
|
148
|
+
├── .ssh-release.lock/
|
|
133
149
|
└── .ssh-release-tmp/
|
|
134
150
|
```
|
|
135
151
|
|
|
136
152
|
规则:
|
|
137
153
|
|
|
154
|
+
- 发布和回滚开始前会创建 `.ssh-release.lock`,避免同一目标目录并发修改。
|
|
155
|
+
- 发布或回滚结束后会自动删除 `.ssh-release.lock`。
|
|
138
156
|
- 每次发布创建一个新版本目录。
|
|
139
157
|
- 新版本完整上传和解压完成后,才切换 `current`。
|
|
140
158
|
- 回滚只切换 `current`,不删除版本目录。
|
|
@@ -150,6 +168,12 @@ source files -> package -> upload -> release -> activate -> rollback
|
|
|
150
168
|
ssh-release doctor
|
|
151
169
|
```
|
|
152
170
|
|
|
171
|
+
需要先查看发布计划但不修改服务器时:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
ssh-release deploy --dry-run
|
|
175
|
+
```
|
|
176
|
+
|
|
153
177
|
执行发布:
|
|
154
178
|
|
|
155
179
|
```bash
|
|
@@ -158,6 +182,8 @@ ssh-release deploy
|
|
|
158
182
|
|
|
159
183
|
`release` 模式发布成功后会输出版本号、版本目录和 `current` 软链接路径。只有压缩包上传和解压完成后才切换 `current`。
|
|
160
184
|
|
|
185
|
+
如果远端已有 `.ssh-release.lock`,说明同一目标目录可能正在发布或回滚,工具会停止在修改远端状态之前。确认没有任务运行后,可以手动删除 `target.path/.ssh-release.lock` 再重试。
|
|
186
|
+
|
|
161
187
|
远端 `tar` 不可用或解压失败时,如果 `deploy.fallbackToFileUpload` 为 `true`,工具会回退逐文件上传。`release` 模式只清理失败的新版本目录;`overwrite` 模式不会先删除整个目标目录。
|
|
162
188
|
|
|
163
189
|
查看版本:
|
|
@@ -239,6 +265,7 @@ npm run build
|
|
|
239
265
|
- `npm run lint`:运行 TypeScript 静态检查。
|
|
240
266
|
- `npm test`:运行 Node.js 测试。
|
|
241
267
|
- `npm run build`:编译 `dist/`。
|
|
268
|
+
- GitHub Actions 会在 `main` 推送和 Pull Request 上运行 `npm ci`、`lint`、`test` 和 `build`。
|
|
242
269
|
|
|
243
270
|
## 发布前检查
|
|
244
271
|
|
|
@@ -270,10 +297,13 @@ npm install -g "$PACK_DIR"/ssh-release-"$VERSION".tgz --prefix "$PACK_DIR/prefix
|
|
|
270
297
|
|
|
271
298
|
```text
|
|
272
299
|
CHANGELOG.md
|
|
300
|
+
.github/
|
|
301
|
+
└── workflows/ci.yml
|
|
273
302
|
src/
|
|
274
303
|
├── cli.ts
|
|
275
304
|
├── config.ts
|
|
276
305
|
├── doctor.ts
|
|
306
|
+
├── lock.ts
|
|
277
307
|
├── list.ts
|
|
278
308
|
├── package.ts
|
|
279
309
|
├── process.ts
|
|
@@ -316,6 +346,7 @@ docs/
|
|
|
316
346
|
- 本地 `.tgz` 打包和排除项。
|
|
317
347
|
- macOS AppleDouble 元数据排除。
|
|
318
348
|
- `release` 和 `overwrite` 发布流程。
|
|
349
|
+
- 发布和回滚锁获取、释放和锁冲突拦截。
|
|
319
350
|
- 远端 `tar` 失败后的逐文件上传回退。
|
|
320
351
|
- 远程版本列表读取和当前版本标记。
|
|
321
352
|
- `doctor` 检查结果。
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
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 { 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
|
export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
@@ -16,16 +16,30 @@ export function isCliEntrypoint(moduleUrl, argvEntry) {
|
|
|
16
16
|
}
|
|
17
17
|
export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
18
18
|
const io = options.io ?? console;
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const parsed = parseCliArgs(argv);
|
|
20
|
+
if (parsed.error) {
|
|
21
|
+
io.error(parsed.error);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (parsed.help) {
|
|
25
|
+
printUsage(io);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
if (parsed.version) {
|
|
29
|
+
io.log(readPackageVersion());
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const handlers = options.handlers ?? createDefaultHandlers(parsed.configPath);
|
|
33
|
+
const command = parsed.command;
|
|
34
|
+
const args = parsed.args;
|
|
21
35
|
try {
|
|
22
36
|
if (command === 'init') {
|
|
23
37
|
await handlers.init();
|
|
24
|
-
io.log(
|
|
38
|
+
io.log(`已创建 ${parsed.configPath}`);
|
|
25
39
|
return 0;
|
|
26
40
|
}
|
|
27
41
|
if (command === 'deploy') {
|
|
28
|
-
printDeployResult(await handlers.deploy(), io);
|
|
42
|
+
printDeployResult(await handlers.deploy({ dryRun: parsed.dryRun }), io);
|
|
29
43
|
return 0;
|
|
30
44
|
}
|
|
31
45
|
if (command === 'rollback') {
|
|
@@ -41,12 +55,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
41
55
|
printDoctorReport(report, io);
|
|
42
56
|
return report.ok ? 0 : 1;
|
|
43
57
|
}
|
|
44
|
-
io
|
|
45
|
-
ssh-release init
|
|
46
|
-
ssh-release deploy
|
|
47
|
-
ssh-release rollback [version]
|
|
48
|
-
ssh-release list
|
|
49
|
-
ssh-release doctor`);
|
|
58
|
+
printUsage(io);
|
|
50
59
|
return command ? 1 : 0;
|
|
51
60
|
}
|
|
52
61
|
catch (error) {
|
|
@@ -54,27 +63,96 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
|
|
|
54
63
|
return 1;
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
|
-
function
|
|
66
|
+
function parseCliArgs(argv) {
|
|
67
|
+
const args = [];
|
|
68
|
+
let configPath = CONFIG_FILE_NAME;
|
|
69
|
+
let dryRun = false;
|
|
70
|
+
let help = false;
|
|
71
|
+
let version = false;
|
|
72
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
73
|
+
const arg = argv[index];
|
|
74
|
+
if (arg === '--help' || arg === '-h') {
|
|
75
|
+
help = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === '--version' || arg === '-v') {
|
|
79
|
+
version = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === '--config' || arg === '-c') {
|
|
83
|
+
const value = argv[index + 1];
|
|
84
|
+
if (!value || value.startsWith('-')) {
|
|
85
|
+
return createParsedError('--config 需要配置文件路径', configPath);
|
|
86
|
+
}
|
|
87
|
+
configPath = value;
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === '--dry-run') {
|
|
92
|
+
dryRun = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (arg.startsWith('-')) {
|
|
96
|
+
return createParsedError(`未知选项: ${arg}`, configPath);
|
|
97
|
+
}
|
|
98
|
+
args.push(arg);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
args: args.slice(1),
|
|
102
|
+
command: args[0],
|
|
103
|
+
configPath,
|
|
104
|
+
dryRun,
|
|
105
|
+
help,
|
|
106
|
+
version,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function createParsedError(error, configPath) {
|
|
58
110
|
return {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
111
|
+
args: [],
|
|
112
|
+
configPath,
|
|
113
|
+
dryRun: false,
|
|
114
|
+
error,
|
|
115
|
+
help: false,
|
|
116
|
+
version: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function createDefaultHandlers(configPath) {
|
|
120
|
+
return {
|
|
121
|
+
init: () => writeConfigTemplate(configPath),
|
|
122
|
+
deploy: async (options) => {
|
|
123
|
+
const config = await loadConfigFile(configPath);
|
|
124
|
+
if (options?.dryRun) {
|
|
125
|
+
return createDeployPlan(config);
|
|
126
|
+
}
|
|
62
127
|
return deploy(config, createRemoteClient(config));
|
|
63
128
|
},
|
|
64
129
|
rollback: async (version) => {
|
|
65
|
-
const config = await loadConfigFile();
|
|
130
|
+
const config = await loadConfigFile(configPath);
|
|
66
131
|
return rollback(config, createRemoteClient(config), version);
|
|
67
132
|
},
|
|
68
133
|
list: async () => {
|
|
69
|
-
const config = await loadConfigFile();
|
|
134
|
+
const config = await loadConfigFile(configPath);
|
|
70
135
|
return listReleases(config, createRemoteClient(config));
|
|
71
136
|
},
|
|
72
137
|
doctor: async () => {
|
|
73
|
-
return runDoctorFromFile(
|
|
138
|
+
return runDoctorFromFile(configPath, createRemoteClient);
|
|
74
139
|
},
|
|
75
140
|
};
|
|
76
141
|
}
|
|
77
142
|
function printDeployResult(result, io) {
|
|
143
|
+
if ('dryRun' in result) {
|
|
144
|
+
io.log('发布预检通过,不会修改远程服务器');
|
|
145
|
+
io.log(`模式: ${result.mode}`);
|
|
146
|
+
io.log(`源路径: ${result.sourcePath}`);
|
|
147
|
+
io.log(`目标目录: ${result.targetPath}`);
|
|
148
|
+
if (result.version) {
|
|
149
|
+
io.log(`计划版本: ${result.version}`);
|
|
150
|
+
}
|
|
151
|
+
if (result.currentSymlink) {
|
|
152
|
+
io.log(`计划切换: ${result.currentSymlink}`);
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
78
156
|
if (result.mode === 'release') {
|
|
79
157
|
io.log(`发布成功: ${result.version}`);
|
|
80
158
|
io.log(`版本目录: ${result.targetPath}`);
|
|
@@ -94,6 +172,9 @@ function printDeployResult(result, io) {
|
|
|
94
172
|
function printRollbackResult(result, io) {
|
|
95
173
|
io.log(`已回滚到版本: ${result.version}`);
|
|
96
174
|
io.log(`当前指向: ${result.currentSymlink}`);
|
|
175
|
+
for (const warning of result.warnings) {
|
|
176
|
+
io.log(`警告: ${warning}`);
|
|
177
|
+
}
|
|
97
178
|
}
|
|
98
179
|
function printListResult(result, io) {
|
|
99
180
|
io.log(`远程目录: ${result.targetPath}`);
|
|
@@ -120,6 +201,21 @@ function toRealPath(filePath) {
|
|
|
120
201
|
return resolve(filePath);
|
|
121
202
|
}
|
|
122
203
|
}
|
|
204
|
+
function printUsage(io) {
|
|
205
|
+
io.log(`用法:
|
|
206
|
+
ssh-release init [--config <path>]
|
|
207
|
+
ssh-release doctor [--config <path>]
|
|
208
|
+
ssh-release deploy [--config <path>]
|
|
209
|
+
ssh-release deploy --dry-run [--config <path>]
|
|
210
|
+
ssh-release list [--config <path>]
|
|
211
|
+
ssh-release rollback [version] [--config <path>]
|
|
212
|
+
ssh-release --help
|
|
213
|
+
ssh-release --version`);
|
|
214
|
+
}
|
|
215
|
+
function readPackageVersion() {
|
|
216
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
217
|
+
return packageJson.version ?? '0.0.0';
|
|
218
|
+
}
|
|
123
219
|
if (isCliEntrypoint(import.meta.url, process.argv[1])) {
|
|
124
220
|
const exitCode = await runCli();
|
|
125
221
|
process.exit(exitCode);
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { remoteJoin, shellQuote } from './remote.js';
|
|
2
|
+
const remoteLockDir = '.ssh-release.lock';
|
|
3
|
+
export function getRemoteLockPath(config) {
|
|
4
|
+
return remoteJoin(config.target.path, remoteLockDir);
|
|
5
|
+
}
|
|
6
|
+
export async function acquireRemoteLock(config, client, options = {}) {
|
|
7
|
+
const createTargetPath = options.createTargetPath ?? true;
|
|
8
|
+
const lockPath = getRemoteLockPath(config);
|
|
9
|
+
const createdAtPath = remoteJoin(lockPath, 'created_at');
|
|
10
|
+
const pidPath = remoteJoin(lockPath, 'pid');
|
|
11
|
+
const lockedMessage = `远程已有发布任务正在运行,请确认后删除 ${lockPath}`;
|
|
12
|
+
const missingTargetMessage = `远程目标目录不存在: ${config.target.path}`;
|
|
13
|
+
const prepareTarget = createTargetPath
|
|
14
|
+
? `mkdir -p ${shellQuote(config.target.path)}`
|
|
15
|
+
: `if [ -d ${shellQuote(config.target.path)} ]; then :; else echo ${shellQuote(missingTargetMessage)} >&2; exit 74; fi`;
|
|
16
|
+
const command = `${prepareTarget} && if mkdir ${shellQuote(lockPath)} 2>/dev/null; then printf '%s\\n' "$$" > ${shellQuote(pidPath)}; date -u '+%Y-%m-%dT%H:%M:%SZ' > ${shellQuote(createdAtPath)}; else echo ${shellQuote(lockedMessage)} >&2; exit 73; fi`;
|
|
17
|
+
await client.exec(command);
|
|
18
|
+
return async () => {
|
|
19
|
+
await client.exec(`rm -rf ${shellQuote(lockPath)}`);
|
|
20
|
+
};
|
|
21
|
+
}
|
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 { acquireRemoteLock } from './lock.js';
|
|
3
4
|
import { readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
4
5
|
export function createVersionName(date = new Date()) {
|
|
5
6
|
return [
|
|
@@ -30,21 +31,75 @@ export async function deploy(config, client, options = {}) {
|
|
|
30
31
|
await ensureSourceExists(config.source.path);
|
|
31
32
|
const versionName = createVersionName(options.now ?? new Date());
|
|
32
33
|
const packageFactory = options.createPackage ?? createReleasePackage;
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const releaseLock = await acquireRemoteLock(config, client);
|
|
35
|
+
let releasePackage;
|
|
36
|
+
let result;
|
|
37
|
+
let deployError;
|
|
38
|
+
let cleanupError;
|
|
38
39
|
try {
|
|
40
|
+
releasePackage = await packageFactory({
|
|
41
|
+
sourcePath: config.source.path,
|
|
42
|
+
exclude: config.source.exclude,
|
|
43
|
+
versionName,
|
|
44
|
+
});
|
|
39
45
|
if (config.deploy.mode === 'overwrite') {
|
|
40
|
-
|
|
46
|
+
result = await deployOverwrite(config, client, releasePackage, versionName);
|
|
47
|
+
return result;
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
result = await deployRelease(config, client, releasePackage, versionName);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
deployError = error;
|
|
54
|
+
throw error;
|
|
43
55
|
}
|
|
44
56
|
finally {
|
|
45
|
-
|
|
57
|
+
if (releasePackage) {
|
|
58
|
+
try {
|
|
59
|
+
await releasePackage.cleanup();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
cleanupError = error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await releaseLock();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
70
|
+
if (result) {
|
|
71
|
+
result.warnings.push(message);
|
|
72
|
+
}
|
|
73
|
+
else if (!deployError && !cleanupError) {
|
|
74
|
+
cleanupError = error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cleanupError && !deployError) {
|
|
78
|
+
throw cleanupError;
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
}
|
|
82
|
+
export async function createDeployPlan(config, options = {}) {
|
|
83
|
+
await ensureSourceExists(config.source.path);
|
|
84
|
+
if (config.deploy.mode === 'overwrite') {
|
|
85
|
+
return {
|
|
86
|
+
dryRun: true,
|
|
87
|
+
mode: 'overwrite',
|
|
88
|
+
sourcePath: config.source.path,
|
|
89
|
+
targetPath: config.target.path,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const versionName = createVersionName(options.now ?? new Date());
|
|
93
|
+
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
94
|
+
return {
|
|
95
|
+
dryRun: true,
|
|
96
|
+
mode: 'release',
|
|
97
|
+
version: versionName,
|
|
98
|
+
sourcePath: config.source.path,
|
|
99
|
+
targetPath: remoteJoin(releasesPath, versionName),
|
|
100
|
+
currentSymlink: remoteJoin(config.target.path, config.target.currentSymlink),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
48
103
|
async function deployRelease(config, client, releasePackage, versionName) {
|
|
49
104
|
const warnings = [];
|
|
50
105
|
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
package/dist/rollback.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
2
|
+
import { acquireRemoteLock } from './lock.js';
|
|
2
3
|
export function selectRollbackTarget(selection) {
|
|
3
4
|
const releases = [...new Set(selection.releases)].sort();
|
|
4
5
|
if (selection.requestedVersion) {
|
|
@@ -20,21 +21,49 @@ export async function rollback(config, client, requestedVersion) {
|
|
|
20
21
|
if (config.deploy.mode === 'overwrite') {
|
|
21
22
|
throw new Error('overwrite 模式不支持回滚');
|
|
22
23
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const releaseLock = await acquireRemoteLock(config, client, { createTargetPath: false });
|
|
25
|
+
let result;
|
|
26
|
+
let rollbackError;
|
|
27
|
+
try {
|
|
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
|
+
await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
|
|
41
|
+
result = {
|
|
42
|
+
version: targetVersion,
|
|
43
|
+
currentSymlink: currentSymlinkPath,
|
|
44
|
+
warnings: [],
|
|
45
|
+
};
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
rollbackError = error;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
try {
|
|
54
|
+
await releaseLock();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = `远程发布锁清理失败: ${formatError(error)}`;
|
|
58
|
+
if (result) {
|
|
59
|
+
result.warnings.push(message);
|
|
60
|
+
}
|
|
61
|
+
else if (!rollbackError) {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
29
65
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
requestedVersion,
|
|
34
|
-
});
|
|
35
|
-
await client.exec(`ln -sfn ${shellQuote(remoteJoin(config.target.releasesDir, targetVersion))} ${shellQuote(currentSymlinkPath)}`);
|
|
36
|
-
return {
|
|
37
|
-
version: targetVersion,
|
|
38
|
-
currentSymlink: currentSymlinkPath,
|
|
39
|
-
};
|
|
66
|
+
}
|
|
67
|
+
function formatError(error) {
|
|
68
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
69
|
}
|