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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 - 2026-06-27
4
+
5
+ ### Added
6
+
7
+ - 增加真实服务器 dogfood 脚本和文档,固化一次性 `/tmp/ssh-release-dogfood-*` 发布、回滚、校验和清理流程。
8
+
3
9
  ## 1.3.0 - 2026-06-26
4
10
 
5
11
  ### Added
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)。
@@ -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 (isExcluded(relativePath, exclude)) {
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
- const versionName = createVersionName(releaseDate);
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
- else if (!rollbackError) {
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
- if (exclude.includes(entry.name)) {
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()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssh-release",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A general-purpose SSH file release CLI.",
5
5
  "type": "module",
6
6
  "bin": {