ssh-release 1.3.0 → 1.4.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 +6 -0
- package/README.md +2 -0
- package/dist/exclude.js +22 -0
- package/dist/lock.js +21 -0
- package/dist/manifest.js +2 -22
- package/dist/release.js +17 -1
- package/dist/rollback.js +3 -2
- package/dist/ssh.js +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -422,6 +422,8 @@ npm install -g "$PACK_DIR"/ssh-release-"$VERSION".tgz --prefix "$PACK_DIR/prefix
|
|
|
422
422
|
|
|
423
423
|
完整发布步骤见 [docs/release-checklist.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/release-checklist.md)。
|
|
424
424
|
|
|
425
|
+
真实服务器 dogfood 见 [docs/dogfood.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/dogfood.md)。
|
|
426
|
+
|
|
425
427
|
GitHub Actions 发布模板见 [docs/github-actions.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/github-actions.md)。
|
|
426
428
|
|
|
427
429
|
失败恢复指南见 [docs/recovery.md](https://github.com/JackEngineer/ssh-release/blob/main/docs/recovery.md)。
|
package/dist/exclude.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export function toPosixPath(value) {
|
|
3
|
+
return value.split(path.sep).join(path.posix.sep);
|
|
4
|
+
}
|
|
5
|
+
export function isExcludedPath(relativePath, exclude) {
|
|
6
|
+
const normalizedPath = toPosixPath(relativePath);
|
|
7
|
+
const pathSegments = normalizedPath.split('/');
|
|
8
|
+
return exclude.some((pattern) => {
|
|
9
|
+
const normalizedPattern = toPosixPath(pattern);
|
|
10
|
+
if (!normalizedPattern.includes('/')) {
|
|
11
|
+
return pathSegments.includes(normalizedPattern);
|
|
12
|
+
}
|
|
13
|
+
return wildcardMatch(normalizedPath, normalizedPattern)
|
|
14
|
+
|| wildcardMatch(`./${normalizedPath}`, normalizedPattern);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function wildcardMatch(value, pattern) {
|
|
18
|
+
const escapedPattern = pattern
|
|
19
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
20
|
+
.replaceAll('*', '[^/]*');
|
|
21
|
+
return new RegExp(`^${escapedPattern}$`).test(value);
|
|
22
|
+
}
|
package/dist/lock.js
CHANGED
|
@@ -41,9 +41,30 @@ export async function acquireRemoteLock(config, client, options = {}) {
|
|
|
41
41
|
export async function removeRemoteLock(config, client) {
|
|
42
42
|
await client.exec(`rm -rf ${shellQuote(getRemoteLockPath(config))}`);
|
|
43
43
|
}
|
|
44
|
+
export async function waitForRemoteLockReleased(config, client, options = {}) {
|
|
45
|
+
const label = options.label ?? '远端锁';
|
|
46
|
+
const attempts = Math.max(1, options.attempts ?? 4);
|
|
47
|
+
const delayMs = Math.max(0, options.delayMs ?? 50);
|
|
48
|
+
let lockStatus = await readRemoteLockStatus(config, client);
|
|
49
|
+
for (let attempt = 1; attempt < attempts && lockStatus.locked; attempt += 1) {
|
|
50
|
+
await delay(delayMs);
|
|
51
|
+
lockStatus = await readRemoteLockStatus(config, client);
|
|
52
|
+
}
|
|
53
|
+
if (lockStatus.locked) {
|
|
54
|
+
throw new Error(`${label}未清理: ${lockStatus.lockPath}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
44
57
|
function parseRemoteLockValue(lines, fieldName) {
|
|
45
58
|
const prefix = `${fieldName}=`;
|
|
46
59
|
const line = lines.find((entry) => entry.startsWith(prefix));
|
|
47
60
|
const value = line?.slice(prefix.length).trim();
|
|
48
61
|
return value || undefined;
|
|
49
62
|
}
|
|
63
|
+
async function delay(ms) {
|
|
64
|
+
if (ms === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await new Promise((resolve) => {
|
|
68
|
+
setTimeout(resolve, ms);
|
|
69
|
+
});
|
|
70
|
+
}
|
package/dist/manifest.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { isExcludedPath, toPosixPath } from './exclude.js';
|
|
5
6
|
export async function createReleaseManifest(options) {
|
|
6
7
|
const sourceStat = await stat(options.sourcePath);
|
|
7
8
|
const files = sourceStat.isDirectory()
|
|
@@ -41,7 +42,7 @@ async function collectDirectoryFiles(sourcePath, exclude, relativeDirectory = ''
|
|
|
41
42
|
const files = [];
|
|
42
43
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
43
44
|
const relativePath = toPosixPath(path.join(relativeDirectory, entry.name));
|
|
44
|
-
if (
|
|
45
|
+
if (isExcludedPath(relativePath, exclude)) {
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
48
|
const localPath = path.join(sourcePath, relativePath);
|
|
@@ -63,24 +64,3 @@ async function createManifestFileEntry(localPath, relativePath) {
|
|
|
63
64
|
sha256: createHash('sha256').update(content).digest('hex'),
|
|
64
65
|
};
|
|
65
66
|
}
|
|
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
|
@@ -28,12 +28,28 @@ export function selectReleasesToDelete(releases, currentVersion, keepReleases) {
|
|
|
28
28
|
function pad(value) {
|
|
29
29
|
return value.toString().padStart(2, '0');
|
|
30
30
|
}
|
|
31
|
+
const maxVersionSuffixAttempts = 1000;
|
|
32
|
+
export async function resolveUniqueReleaseVersion(config, client, baseVersionName) {
|
|
33
|
+
const releasesPath = remoteJoin(config.target.path, config.target.releasesDir);
|
|
34
|
+
for (let attempt = 1; attempt <= maxVersionSuffixAttempts; attempt += 1) {
|
|
35
|
+
const candidate = attempt === 1 ? baseVersionName : `${baseVersionName}-${attempt}`;
|
|
36
|
+
const candidatePath = remoteJoin(releasesPath, candidate);
|
|
37
|
+
const result = await client.exec(`test -e ${shellQuote(candidatePath)} && echo exists || true`);
|
|
38
|
+
if (result.stdout.trim() !== 'exists') {
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`无法为版本 ${baseVersionName} 生成唯一目录名,已尝试 ${maxVersionSuffixAttempts} 次`);
|
|
43
|
+
}
|
|
31
44
|
export async function deploy(config, client, options = {}) {
|
|
32
45
|
await withDeployProgress(options, 'source', () => ensureSourceExists(config.source.path));
|
|
33
46
|
const releaseDate = options.now ?? new Date();
|
|
34
|
-
|
|
47
|
+
let versionName = createVersionName(releaseDate);
|
|
35
48
|
const packageFactory = options.createPackage ?? createReleasePackage;
|
|
36
49
|
const releaseLock = await withDeployProgress(options, 'lock', () => acquireRemoteLock(config, client));
|
|
50
|
+
if (config.deploy.mode === 'release') {
|
|
51
|
+
versionName = await resolveUniqueReleaseVersion(config, client, versionName);
|
|
52
|
+
}
|
|
37
53
|
let releasePackage;
|
|
38
54
|
let manifest;
|
|
39
55
|
let manifestFile;
|
package/dist/rollback.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readCurrentVersion, readRemoteReleaseNames, remoteJoin, shellQuote, } from './remote.js';
|
|
2
|
-
import { acquireRemoteLock, readRemoteLockStatus } from './lock.js';
|
|
2
|
+
import { acquireRemoteLock, readRemoteLockStatus, waitForRemoteLockReleased } from './lock.js';
|
|
3
3
|
export function selectRollbackTarget(selection) {
|
|
4
4
|
const releases = [...new Set(selection.releases)].sort();
|
|
5
5
|
if (selection.requestedVersion) {
|
|
@@ -100,13 +100,14 @@ export async function rollback(config, client, requestedVersion, options = {}) {
|
|
|
100
100
|
await withRollbackProgress(options, 'cleanup', async () => {
|
|
101
101
|
try {
|
|
102
102
|
await releaseLock();
|
|
103
|
+
await waitForRemoteLockReleased(config, client, { label: '回滚锁' });
|
|
103
104
|
}
|
|
104
105
|
catch (error) {
|
|
105
106
|
const message = `远程回滚锁清理失败: ${formatError(error)}`;
|
|
106
107
|
if (result) {
|
|
107
108
|
result.warnings.push(message);
|
|
108
109
|
}
|
|
109
|
-
|
|
110
|
+
if (!rollbackError) {
|
|
110
111
|
cleanupError = error;
|
|
111
112
|
}
|
|
112
113
|
}
|
package/dist/ssh.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isExcludedPath, toPosixPath } from './exclude.js';
|
|
3
4
|
import { runProcess } from './process.js';
|
|
4
5
|
import { remoteJoin, shellQuote } from './remote.js';
|
|
5
6
|
export class ShellRemoteClient {
|
|
@@ -119,17 +120,18 @@ async function uploadDirectoryContents(client, localPath, remotePath, exclude) {
|
|
|
119
120
|
}
|
|
120
121
|
await walkDirectory(client, localPath, remotePath, exclude);
|
|
121
122
|
}
|
|
122
|
-
async function walkDirectory(client, localDirectory, remoteDirectory, exclude) {
|
|
123
|
+
export async function walkDirectory(client, localDirectory, remoteDirectory, exclude, relativeDirectory = '') {
|
|
123
124
|
await client.exec(`mkdir -p ${shellQuote(remoteDirectory)}`);
|
|
124
125
|
const entries = await readdir(localDirectory, { withFileTypes: true });
|
|
125
126
|
for (const entry of entries) {
|
|
126
|
-
|
|
127
|
+
const relativeEntryPath = toPosixPath(path.join(relativeDirectory, entry.name));
|
|
128
|
+
if (isExcludedPath(relativeEntryPath, exclude)) {
|
|
127
129
|
continue;
|
|
128
130
|
}
|
|
129
131
|
const localEntryPath = path.join(localDirectory, entry.name);
|
|
130
132
|
const remoteEntryPath = remoteJoin(remoteDirectory, entry.name);
|
|
131
133
|
if (entry.isDirectory()) {
|
|
132
|
-
await walkDirectory(client, localEntryPath, remoteEntryPath, exclude);
|
|
134
|
+
await walkDirectory(client, localEntryPath, remoteEntryPath, exclude, relativeEntryPath);
|
|
133
135
|
continue;
|
|
134
136
|
}
|
|
135
137
|
if (entry.isFile()) {
|