@wyxos/zephyr 0.4.5 → 0.4.7
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/README.md +4 -0
- package/package.json +1 -1
- package/src/application/deploy/build-remote-deployment-plan.mjs +4 -12
- package/src/application/deploy/run-deployment.mjs +197 -16
- package/src/application/release/release-node-package.mjs +9 -1
- package/src/application/release/release-packagist-package.mjs +9 -1
- package/src/infrastructure/php/version.mjs +80 -23
- package/src/main.mjs +24 -1
- package/src/release/commit-message.mjs +331 -0
- package/src/release/shared.mjs +97 -14
- package/src/runtime/app-context.mjs +4 -0
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ When `--type node` or `--type vue` is used without a bump argument, Zephyr defau
|
|
|
68
68
|
|
|
69
69
|
Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
|
|
70
70
|
|
|
71
|
+
For app deployments, interactive mode now requires a real interactive terminal. If stdin/stdout are not attached to a TTY, Zephyr refuses to continue in interactive mode and tells you to rerun with --non-interactive --preset <name> --maintenance on|off.
|
|
72
|
+
|
|
71
73
|
Non-interactive mode is strict and is intended for already-configured projects:
|
|
72
74
|
|
|
73
75
|
- `--non-interactive` fails instead of prompting
|
|
@@ -77,6 +79,8 @@ Non-interactive mode is strict and is intended for already-configured projects:
|
|
|
77
79
|
- stale remote locks are never auto-removed in non-interactive mode
|
|
78
80
|
- `--json` is only supported together with `--non-interactive`
|
|
79
81
|
|
|
82
|
+
If Laravel maintenance mode has already been enabled and Zephyr then exits abnormally because of a signal such as `SIGINT`, `SIGTERM`, or `SIGHUP`, it now makes a best-effort attempt to run `artisan up` automatically before exiting.
|
|
83
|
+
|
|
80
84
|
If Zephyr would normally prompt to:
|
|
81
85
|
|
|
82
86
|
- create or repair config
|
package/package.json
CHANGED
|
@@ -190,24 +190,16 @@ async function resolvePhpCommand({
|
|
|
190
190
|
requiredPhpVersion,
|
|
191
191
|
ssh,
|
|
192
192
|
remoteCwd,
|
|
193
|
-
logProcessing
|
|
194
|
-
logWarning
|
|
193
|
+
logProcessing
|
|
195
194
|
} = {}) {
|
|
196
195
|
if (!requiredPhpVersion) {
|
|
197
196
|
return 'php'
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (phpCommand !== 'php') {
|
|
203
|
-
logProcessing?.(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
|
|
204
|
-
}
|
|
199
|
+
const phpCommand = await findPhpBinary(ssh, remoteCwd, requiredPhpVersion)
|
|
200
|
+
logProcessing?.(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
|
|
205
201
|
|
|
206
|
-
|
|
207
|
-
} catch (error) {
|
|
208
|
-
logWarning?.(`Could not find PHP binary for version ${requiredPhpVersion}: ${error.message}`)
|
|
209
|
-
return 'php'
|
|
210
|
-
}
|
|
202
|
+
return phpCommand
|
|
211
203
|
}
|
|
212
204
|
|
|
213
205
|
function createMaintenanceModePlan({
|
|
@@ -59,7 +59,9 @@ async function maybeRecoverLaravelMaintenanceMode({
|
|
|
59
59
|
runPrompt,
|
|
60
60
|
logProcessing,
|
|
61
61
|
logWarning,
|
|
62
|
-
executionMode = {}
|
|
62
|
+
executionMode = {},
|
|
63
|
+
forceAutoRecovery = false,
|
|
64
|
+
reason = null
|
|
63
65
|
} = {}) {
|
|
64
66
|
if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
|
|
65
67
|
return
|
|
@@ -75,8 +77,11 @@ async function maybeRecoverLaravelMaintenanceMode({
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
try {
|
|
78
|
-
if (executionMode?.interactive === false) {
|
|
79
|
-
|
|
80
|
+
if (forceAutoRecovery || executionMode?.interactive === false) {
|
|
81
|
+
const reasonSuffix = typeof reason === 'string' && reason.length > 0
|
|
82
|
+
? ` because of ${reason}`
|
|
83
|
+
: ''
|
|
84
|
+
logProcessing?.(`Deployment interrupted${reasonSuffix} after Laravel maintenance mode was enabled. Running \`artisan up\` automatically...`)
|
|
80
85
|
await executeRemote(
|
|
81
86
|
'Disable Laravel maintenance mode',
|
|
82
87
|
remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
|
|
@@ -115,6 +120,129 @@ async function maybeRecoverLaravelMaintenanceMode({
|
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
function signalToExitCode(signal) {
|
|
124
|
+
const signalNumbers = {
|
|
125
|
+
SIGHUP: 1,
|
|
126
|
+
SIGINT: 2,
|
|
127
|
+
SIGTERM: 15
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!signalNumbers[signal]) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return 128 + signalNumbers[signal]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createAbnormalExitGuard({
|
|
138
|
+
processRef = process,
|
|
139
|
+
cleanup = async () => {},
|
|
140
|
+
terminate = null,
|
|
141
|
+
logWarning
|
|
142
|
+
} = {}) {
|
|
143
|
+
const listeners = new Map()
|
|
144
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
|
|
145
|
+
let active = true
|
|
146
|
+
let cleanupPromise = null
|
|
147
|
+
|
|
148
|
+
const terminateProcess = typeof terminate === 'function'
|
|
149
|
+
? terminate
|
|
150
|
+
: async (signal) => {
|
|
151
|
+
const exitCode = signalToExitCode(signal)
|
|
152
|
+
|
|
153
|
+
if (typeof exitCode === 'number') {
|
|
154
|
+
processRef.exitCode = exitCode
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof processRef.kill === 'function' && typeof processRef.pid === 'number') {
|
|
158
|
+
processRef.kill(processRef.pid, signal)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const unregister = () => {
|
|
163
|
+
if (!active) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
active = false
|
|
168
|
+
|
|
169
|
+
for (const [signal, handler] of listeners.entries()) {
|
|
170
|
+
if (typeof processRef.off === 'function') {
|
|
171
|
+
processRef.off(signal, handler)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (typeof processRef.removeListener === 'function') {
|
|
176
|
+
processRef.removeListener(signal, handler)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
listeners.clear()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const run = async (signal) => {
|
|
184
|
+
if (!active) {
|
|
185
|
+
return cleanupPromise
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (cleanupPromise) {
|
|
189
|
+
return cleanupPromise
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
unregister()
|
|
193
|
+
cleanupPromise = (async () => {
|
|
194
|
+
try {
|
|
195
|
+
await cleanup(signal)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logWarning?.(`Best-effort deploy recovery after ${signal} failed: ${error.message}`)
|
|
198
|
+
} finally {
|
|
199
|
+
await terminateProcess(signal)
|
|
200
|
+
}
|
|
201
|
+
})()
|
|
202
|
+
|
|
203
|
+
return cleanupPromise
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const signal of signals) {
|
|
207
|
+
const handler = () => {
|
|
208
|
+
void run(signal)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
listeners.set(signal, handler)
|
|
212
|
+
processRef.once(signal, handler)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
unregister,
|
|
217
|
+
run
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function cleanupDeploymentResources({
|
|
222
|
+
rootDir,
|
|
223
|
+
ssh,
|
|
224
|
+
remoteCwd,
|
|
225
|
+
lockAcquired,
|
|
226
|
+
logWarning
|
|
227
|
+
} = {}) {
|
|
228
|
+
if (lockAcquired && ssh && remoteCwd) {
|
|
229
|
+
try {
|
|
230
|
+
await releaseRemoteLock(ssh, remoteCwd, {logWarning})
|
|
231
|
+
await releaseLocalLock(rootDir, {logWarning})
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logWarning?.(`Failed to release lock: ${error.message}`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await closeLogFile()
|
|
238
|
+
|
|
239
|
+
if (ssh) {
|
|
240
|
+
ssh.dispose()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export {maybeRecoverLaravelMaintenanceMode}
|
|
245
|
+
|
|
118
246
|
export async function runDeployment(config, options = {}) {
|
|
119
247
|
const {
|
|
120
248
|
snapshot = null,
|
|
@@ -150,6 +278,64 @@ export async function runDeployment(config, options = {}) {
|
|
|
150
278
|
}
|
|
151
279
|
|
|
152
280
|
let lockAcquired = false
|
|
281
|
+
const abnormalExitGuard = createAbnormalExitGuard({
|
|
282
|
+
logWarning,
|
|
283
|
+
cleanup: async (signal) => {
|
|
284
|
+
let recoverySsh = null
|
|
285
|
+
let recoveryExecutor = executeRemote
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
if (remoteCwd) {
|
|
289
|
+
({ssh: recoverySsh} = await connectToRemoteDeploymentTarget({
|
|
290
|
+
config,
|
|
291
|
+
createSshClient,
|
|
292
|
+
sshUser,
|
|
293
|
+
privateKey,
|
|
294
|
+
remoteCwd,
|
|
295
|
+
logProcessing,
|
|
296
|
+
message: `Reconnecting to ${config.serverIp} as ${sshUser} for abnormal-exit recovery...`
|
|
297
|
+
}))
|
|
298
|
+
|
|
299
|
+
recoveryExecutor = createRemoteExecutor({
|
|
300
|
+
ssh: recoverySsh,
|
|
301
|
+
rootDir,
|
|
302
|
+
remoteCwd,
|
|
303
|
+
writeToLogFile,
|
|
304
|
+
logProcessing,
|
|
305
|
+
logSuccess,
|
|
306
|
+
logError
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await maybeRecoverLaravelMaintenanceMode({
|
|
311
|
+
remotePlan,
|
|
312
|
+
executionState,
|
|
313
|
+
executeRemote: recoveryExecutor,
|
|
314
|
+
runPrompt,
|
|
315
|
+
logProcessing,
|
|
316
|
+
logWarning,
|
|
317
|
+
executionMode,
|
|
318
|
+
forceAutoRecovery: true,
|
|
319
|
+
reason: signal
|
|
320
|
+
})
|
|
321
|
+
} finally {
|
|
322
|
+
await cleanupDeploymentResources({
|
|
323
|
+
rootDir,
|
|
324
|
+
ssh: recoverySsh ?? ssh,
|
|
325
|
+
remoteCwd,
|
|
326
|
+
lockAcquired,
|
|
327
|
+
logWarning
|
|
328
|
+
})
|
|
329
|
+
lockAcquired = false
|
|
330
|
+
|
|
331
|
+
if (recoverySsh && ssh && recoverySsh !== ssh) {
|
|
332
|
+
ssh.dispose()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
ssh = null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
})
|
|
153
339
|
|
|
154
340
|
try {
|
|
155
341
|
({ssh, remoteCwd} = await connectToRemoteDeploymentTarget({
|
|
@@ -280,18 +466,13 @@ export async function runDeployment(config, options = {}) {
|
|
|
280
466
|
|
|
281
467
|
throw new Error(`Deployment failed: ${error.message}`)
|
|
282
468
|
} finally {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
await closeLogFile()
|
|
293
|
-
if (ssh) {
|
|
294
|
-
ssh.dispose()
|
|
295
|
-
}
|
|
469
|
+
abnormalExitGuard.unregister()
|
|
470
|
+
await cleanupDeploymentResources({
|
|
471
|
+
rootDir,
|
|
472
|
+
ssh,
|
|
473
|
+
remoteCwd,
|
|
474
|
+
lockAcquired,
|
|
475
|
+
logWarning
|
|
476
|
+
})
|
|
296
477
|
}
|
|
297
478
|
}
|
|
@@ -382,7 +382,15 @@ export async function releaseNodePackage({
|
|
|
382
382
|
})
|
|
383
383
|
|
|
384
384
|
logStep?.('Checking working tree status...')
|
|
385
|
-
await ensureCleanWorkingTree(rootDir, {
|
|
385
|
+
await ensureCleanWorkingTree(rootDir, {
|
|
386
|
+
runCommand,
|
|
387
|
+
runPrompt,
|
|
388
|
+
logStep,
|
|
389
|
+
logSuccess,
|
|
390
|
+
logWarning,
|
|
391
|
+
interactive,
|
|
392
|
+
skipGitHooks
|
|
393
|
+
})
|
|
386
394
|
await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
|
|
387
395
|
|
|
388
396
|
await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
|
|
@@ -249,7 +249,15 @@ export async function releasePackagistPackage({
|
|
|
249
249
|
})
|
|
250
250
|
|
|
251
251
|
logStep?.('Checking working tree status...')
|
|
252
|
-
await ensureCleanWorkingTree(rootDir, {
|
|
252
|
+
await ensureCleanWorkingTree(rootDir, {
|
|
253
|
+
runCommand,
|
|
254
|
+
runPrompt,
|
|
255
|
+
logStep,
|
|
256
|
+
logSuccess,
|
|
257
|
+
logWarning,
|
|
258
|
+
interactive,
|
|
259
|
+
skipGitHooks
|
|
260
|
+
})
|
|
253
261
|
await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
|
|
254
262
|
|
|
255
263
|
await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
|
|
@@ -2,6 +2,25 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import semver from 'semver'
|
|
4
4
|
|
|
5
|
+
function normalizeComposerConstraint(constraint) {
|
|
6
|
+
if (typeof constraint !== 'string') {
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return constraint
|
|
11
|
+
.replace(/\s*\|{1,2}\s*/g, ' || ')
|
|
12
|
+
.replace(/,/g, ' ')
|
|
13
|
+
.replace(/\s+@[\w.-]+/g, '')
|
|
14
|
+
.replace(/\s+/g, ' ')
|
|
15
|
+
.trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getHighestVersion(versions = []) {
|
|
19
|
+
return versions
|
|
20
|
+
.filter((version) => semver.valid(version))
|
|
21
|
+
.reduce((highest, version) => (!highest || semver.gt(version, highest) ? version : highest), null)
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
/**
|
|
6
25
|
* Extracts the minimum PHP version requirement from a composer.json object
|
|
7
26
|
* @param {object} composer - Parsed composer.json object
|
|
@@ -13,47 +32,78 @@ export function parsePhpVersionRequirement(composer) {
|
|
|
13
32
|
return null
|
|
14
33
|
}
|
|
15
34
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
const normalizedConstraint = normalizeComposerConstraint(phpRequirement)
|
|
36
|
+
if (normalizedConstraint) {
|
|
37
|
+
const minimumVersion = semver.minVersion(normalizedConstraint)
|
|
38
|
+
if (minimumVersion) {
|
|
39
|
+
return minimumVersion.version
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const versionMatches = [...phpRequirement.matchAll(/(\d+)\.(\d+)(?:\.(\d+))?/g)]
|
|
44
|
+
if (versionMatches.length === 0) {
|
|
20
45
|
return null
|
|
21
46
|
}
|
|
22
47
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
const versions = versionMatches
|
|
49
|
+
.map(([, major, minor, patch = '0']) => semver.coerce(`${major}.${minor}.${patch}`)?.version ?? null)
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
|
|
52
|
+
return versions.length > 0 ? versions.sort(semver.compare)[0] : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseComposerLockPhpVersionRequirement(lock) {
|
|
56
|
+
const versions = []
|
|
57
|
+
const platformPhpVersion = parsePhpVersionRequirement({require: {php: lock?.platform?.php}})
|
|
58
|
+
if (platformPhpVersion) {
|
|
59
|
+
versions.push(platformPhpVersion)
|
|
32
60
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
|
|
62
|
+
const packages = Array.isArray(lock?.packages) ? lock.packages : []
|
|
63
|
+
for (const pkg of packages) {
|
|
64
|
+
const packagePhpVersion = parsePhpVersionRequirement({require: {php: pkg?.require?.php}})
|
|
65
|
+
if (packagePhpVersion) {
|
|
66
|
+
versions.push(packagePhpVersion)
|
|
67
|
+
}
|
|
38
68
|
}
|
|
39
69
|
|
|
40
|
-
return
|
|
70
|
+
return getHighestVersion(versions)
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
/**
|
|
44
|
-
* Extracts the minimum PHP version requirement from composer.json
|
|
74
|
+
* Extracts the effective minimum PHP version requirement from composer.json and composer.lock.
|
|
75
|
+
* The lock file wins when runtime dependencies need a higher version than the root package declares.
|
|
45
76
|
* @param {string} rootDir - Project root directory
|
|
46
77
|
* @returns {Promise<string|null>} - PHP version requirement (e.g., "8.4.0") or null
|
|
47
78
|
*/
|
|
48
79
|
export async function getPhpVersionRequirement(rootDir) {
|
|
80
|
+
const versions = []
|
|
81
|
+
|
|
49
82
|
try {
|
|
50
83
|
const composerPath = path.join(rootDir, 'composer.json')
|
|
51
84
|
const raw = await fs.readFile(composerPath, 'utf8')
|
|
52
85
|
const composer = JSON.parse(raw)
|
|
53
|
-
|
|
86
|
+
const composerPhpVersion = parsePhpVersionRequirement(composer)
|
|
87
|
+
if (composerPhpVersion) {
|
|
88
|
+
versions.push(composerPhpVersion)
|
|
89
|
+
}
|
|
54
90
|
} catch {
|
|
55
|
-
|
|
91
|
+
// Ignore and continue to composer.lock if present.
|
|
56
92
|
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const lockPath = path.join(rootDir, 'composer.lock')
|
|
96
|
+
const raw = await fs.readFile(lockPath, 'utf8')
|
|
97
|
+
const lock = JSON.parse(raw)
|
|
98
|
+
const lockPhpVersion = parseComposerLockPhpVersionRequirement(lock)
|
|
99
|
+
if (lockPhpVersion) {
|
|
100
|
+
versions.push(lockPhpVersion)
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore when composer.lock is absent or unreadable.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return getHighestVersion(versions)
|
|
57
107
|
}
|
|
58
108
|
|
|
59
109
|
const RUNCLOUD_PACKAGES = '/RunCloud/Packages'
|
|
@@ -126,6 +176,8 @@ export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
|
|
|
126
176
|
return 'php'
|
|
127
177
|
}
|
|
128
178
|
|
|
179
|
+
let defaultPhpVersion = null
|
|
180
|
+
|
|
129
181
|
const majorMinor = semver.major(requiredVersion) + '.' + semver.minor(requiredVersion)
|
|
130
182
|
const versionedPhp = `php${majorMinor.replace('.', '')}` // e.g., "php84"
|
|
131
183
|
|
|
@@ -175,11 +227,16 @@ export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
|
|
|
175
227
|
if (actualVersion && satisfiesVersion(actualVersion, requiredVersion)) {
|
|
176
228
|
return 'php'
|
|
177
229
|
}
|
|
230
|
+
defaultPhpVersion = actualVersion
|
|
178
231
|
} catch {
|
|
179
232
|
// Ignore
|
|
180
233
|
}
|
|
181
234
|
|
|
182
|
-
|
|
235
|
+
const defaultVersionHint = defaultPhpVersion
|
|
236
|
+
? ` The default php command reports ${defaultPhpVersion}.`
|
|
237
|
+
: ''
|
|
238
|
+
|
|
239
|
+
throw new Error(`No PHP binary satisfying ${requiredVersion} was found on the remote server.${defaultVersionHint}`)
|
|
183
240
|
}
|
|
184
241
|
|
|
185
242
|
/**
|
package/src/main.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {releaseNode} from './release-node.mjs'
|
|
|
8
8
|
import {releasePackagist} from './release-packagist.mjs'
|
|
9
9
|
import {validateLocalDependencies} from './dependency-scanner.mjs'
|
|
10
10
|
import * as bootstrap from './project/bootstrap.mjs'
|
|
11
|
-
import {getErrorCode} from './runtime/errors.mjs'
|
|
11
|
+
import {getErrorCode, ZephyrError} from './runtime/errors.mjs'
|
|
12
12
|
import {PROJECT_CONFIG_DIR} from './utils/paths.mjs'
|
|
13
13
|
import {writeStderrLine} from './utils/output.mjs'
|
|
14
14
|
import {createAppContext} from './runtime/app-context.mjs'
|
|
@@ -73,6 +73,23 @@ function resolveWorkflowName(workflowType = null) {
|
|
|
73
73
|
return 'deploy'
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function assertInteractiveAppDeploySession({workflowType = null, executionMode = {}, appContext = {}} = {}) {
|
|
77
|
+
const isAppDeploy = workflowType !== 'node' && workflowType !== 'vue' && workflowType !== 'packagist'
|
|
78
|
+
|
|
79
|
+
if (!isAppDeploy || executionMode?.interactive === false) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (appContext?.hasInteractiveTerminal !== false) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new ZephyrError(
|
|
88
|
+
'Zephyr refuses interactive app deployments without a real interactive terminal. Rerun in a TTY, or use --non-interactive --preset <name> --maintenance on|off.',
|
|
89
|
+
{code: 'ZEPHYR_INTERACTIVE_SESSION_REQUIRED'}
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
async function runRemoteTasks(config, options = {}) {
|
|
77
94
|
return await runDeployment(config, {
|
|
78
95
|
...options,
|
|
@@ -131,6 +148,12 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
131
148
|
logWarning(SKIP_GIT_HOOKS_WARNING)
|
|
132
149
|
}
|
|
133
150
|
|
|
151
|
+
assertInteractiveAppDeploySession({
|
|
152
|
+
workflowType: options.workflowType,
|
|
153
|
+
executionMode: currentExecutionMode,
|
|
154
|
+
appContext
|
|
155
|
+
})
|
|
156
|
+
|
|
134
157
|
if (options.workflowType === 'node' || options.workflowType === 'vue') {
|
|
135
158
|
await releaseNode({
|
|
136
159
|
releaseType: options.versionArg,
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {mkdtemp, readFile, rm} from 'node:fs/promises'
|
|
2
|
+
import {tmpdir} from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
import {commandExists} from '../utils/command.mjs'
|
|
7
|
+
|
|
8
|
+
const CONVENTIONAL_COMMIT_PATTERN = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test): .+/i
|
|
9
|
+
const GENERIC_SUBJECT_PATTERNS = [
|
|
10
|
+
/^commit pending (release )?changes$/i,
|
|
11
|
+
/^pending (release )?changes$/i,
|
|
12
|
+
/^commit changes$/i,
|
|
13
|
+
/^update changes$/i,
|
|
14
|
+
/^update files$/i,
|
|
15
|
+
/^update work$/i,
|
|
16
|
+
/^misc(ellaneous)?( updates?)?$/i,
|
|
17
|
+
/^changes$/i,
|
|
18
|
+
/^updates?$/i
|
|
19
|
+
]
|
|
20
|
+
const MAX_WORKING_TREE_PREVIEW = 20
|
|
21
|
+
const STATUS_LABELS = {
|
|
22
|
+
A: 'added',
|
|
23
|
+
C: 'copied',
|
|
24
|
+
D: 'deleted',
|
|
25
|
+
M: 'modified',
|
|
26
|
+
R: 'renamed',
|
|
27
|
+
T: 'type-changed',
|
|
28
|
+
U: 'conflicted'
|
|
29
|
+
}
|
|
30
|
+
const TOPIC_STOP_WORDS = new Set([
|
|
31
|
+
'src',
|
|
32
|
+
'test',
|
|
33
|
+
'tests',
|
|
34
|
+
'__tests__',
|
|
35
|
+
'spec',
|
|
36
|
+
'specs',
|
|
37
|
+
'app',
|
|
38
|
+
'lib',
|
|
39
|
+
'dist',
|
|
40
|
+
'packages',
|
|
41
|
+
'package',
|
|
42
|
+
'application',
|
|
43
|
+
'shared',
|
|
44
|
+
'index',
|
|
45
|
+
'main',
|
|
46
|
+
'js',
|
|
47
|
+
'jsx',
|
|
48
|
+
'ts',
|
|
49
|
+
'tsx',
|
|
50
|
+
'mjs',
|
|
51
|
+
'cjs',
|
|
52
|
+
'php',
|
|
53
|
+
'json',
|
|
54
|
+
'yaml',
|
|
55
|
+
'yml',
|
|
56
|
+
'md',
|
|
57
|
+
'toml',
|
|
58
|
+
'lock'
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
function resolveWorkingTreeEntryLabel(entry) {
|
|
62
|
+
if (entry.indexStatus === '?' && entry.worktreeStatus === '?') {
|
|
63
|
+
return 'untracked'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (entry.indexStatus === '!' && entry.worktreeStatus === '!') {
|
|
67
|
+
return 'ignored'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const relevantStatuses = [entry.indexStatus, entry.worktreeStatus].filter((status) => status && status !== ' ')
|
|
71
|
+
for (const status of relevantStatuses) {
|
|
72
|
+
if (STATUS_LABELS[status]) {
|
|
73
|
+
return STATUS_LABELS[status]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return 'changed'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function tokenizePath(pathValue = '') {
|
|
81
|
+
return pathValue
|
|
82
|
+
.split(/[\\/]/)
|
|
83
|
+
.flatMap((segment) => segment.split(/[^a-zA-Z0-9]+/))
|
|
84
|
+
.map((token) => token.toLowerCase())
|
|
85
|
+
.filter((token) => token.length >= 3 && !TOPIC_STOP_WORDS.has(token))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function inferCommitTypeFromEntries(statusEntries = []) {
|
|
89
|
+
const paths = statusEntries.map((entry) => entry.path.toLowerCase())
|
|
90
|
+
|
|
91
|
+
if (paths.every((pathValue) => pathValue.endsWith('.md') || pathValue.includes('/docs/') || pathValue.startsWith('docs/'))) {
|
|
92
|
+
return 'docs'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (paths.every((pathValue) => /\.test\.[^.]+$/.test(pathValue) || pathValue.includes('/tests/'))) {
|
|
96
|
+
return 'test'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (paths.some((pathValue) => pathValue.includes('.github/workflows/') || pathValue.includes('/ci/'))) {
|
|
100
|
+
return 'ci'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return 'chore'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseWorkingTreeStatus(stdout = '') {
|
|
107
|
+
return stdout
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
.map((line) => line.trimEnd())
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseWorkingTreeEntries(stdout = '') {
|
|
114
|
+
return parseWorkingTreeStatus(stdout).map((line) => {
|
|
115
|
+
const indexStatus = line.slice(0, 1)
|
|
116
|
+
const worktreeStatus = line.slice(1, 2)
|
|
117
|
+
const rawPath = line.slice(3).trim()
|
|
118
|
+
const isRename = [indexStatus, worktreeStatus].some((status) => status === 'R' || status === 'C')
|
|
119
|
+
const [fromPath, toPath] = isRename && rawPath.includes(' -> ')
|
|
120
|
+
? rawPath.split(' -> ')
|
|
121
|
+
: [null, null]
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
raw: line,
|
|
125
|
+
indexStatus,
|
|
126
|
+
worktreeStatus,
|
|
127
|
+
path: toPath ?? rawPath,
|
|
128
|
+
previousPath: fromPath
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function summarizeWorkingTreeEntry(entry, {
|
|
134
|
+
changeCountsByPath = new Map()
|
|
135
|
+
} = {}) {
|
|
136
|
+
const label = resolveWorkingTreeEntryLabel(entry)
|
|
137
|
+
const displayPath = entry.previousPath ? `${entry.previousPath} -> ${entry.path}` : entry.path
|
|
138
|
+
const counts = changeCountsByPath.get(entry.path) ?? null
|
|
139
|
+
|
|
140
|
+
if (!counts) {
|
|
141
|
+
return `${label}: ${displayPath}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return `${label}: ${displayPath} (+${counts.added} -${counts.deleted})`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatWorkingTreePreview(statusEntries = []) {
|
|
148
|
+
const preview = statusEntries
|
|
149
|
+
.slice(0, MAX_WORKING_TREE_PREVIEW)
|
|
150
|
+
.map((entry) => ` ${summarizeWorkingTreeEntry(entry)}`)
|
|
151
|
+
.join('\n')
|
|
152
|
+
|
|
153
|
+
if (statusEntries.length <= MAX_WORKING_TREE_PREVIEW) {
|
|
154
|
+
return preview
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const remaining = statusEntries.length - MAX_WORKING_TREE_PREVIEW
|
|
158
|
+
return `${preview}\n ...and ${remaining} more file${remaining === 1 ? '' : 's'}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function sanitizeSuggestedCommitMessage(message) {
|
|
162
|
+
if (typeof message !== 'string') {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const firstLine = message
|
|
167
|
+
.split(/\r?\n/)
|
|
168
|
+
.map((line) => line.trim())
|
|
169
|
+
.find(Boolean)
|
|
170
|
+
|
|
171
|
+
if (!firstLine) {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const normalized = firstLine
|
|
176
|
+
.replace(/^commit message:\s*/i, '')
|
|
177
|
+
.replace(/^(\w+)\([^)]+\)(!?):/i, '$1:')
|
|
178
|
+
.replace(/^(\w+)!:/i, '$1:')
|
|
179
|
+
.replace(/^["'`]+|["'`]+$/g, '')
|
|
180
|
+
.trim()
|
|
181
|
+
|
|
182
|
+
if (!CONVENTIONAL_COMMIT_PATTERN.test(normalized)) {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const [, subject = ''] = normalized.split(/:\s+/, 2)
|
|
187
|
+
const normalizedSubject = subject.trim()
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
normalizedSubject.length < 18 ||
|
|
191
|
+
normalizedSubject.split(/\s+/).length < 3 ||
|
|
192
|
+
GENERIC_SUBJECT_PATTERNS.some((pattern) => pattern.test(normalizedSubject))
|
|
193
|
+
) {
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return normalized
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function buildFallbackCommitMessage(statusEntries = []) {
|
|
201
|
+
const tokenCounts = new Map()
|
|
202
|
+
|
|
203
|
+
for (const entry of statusEntries) {
|
|
204
|
+
for (const token of tokenizePath(entry.path)) {
|
|
205
|
+
tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const orderedTokens = Array.from(tokenCounts.entries())
|
|
210
|
+
.sort((left, right) => {
|
|
211
|
+
if (right[1] !== left[1]) {
|
|
212
|
+
return right[1] - left[1]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return left[0].localeCompare(right[0])
|
|
216
|
+
})
|
|
217
|
+
.map(([token]) => token)
|
|
218
|
+
|
|
219
|
+
const primaryTopic = orderedTokens[0] ?? 'release'
|
|
220
|
+
const commitType = inferCommitTypeFromEntries(statusEntries)
|
|
221
|
+
|
|
222
|
+
if (commitType === 'docs') {
|
|
223
|
+
return `docs: update ${primaryTopic} documentation`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (commitType === 'test') {
|
|
227
|
+
return `test: expand ${primaryTopic} coverage`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (commitType === 'ci') {
|
|
231
|
+
return `ci: update ${primaryTopic} workflow`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return `chore: improve ${primaryTopic} workflow`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function collectDiffNumstat(rootDir, {runCommand} = {}) {
|
|
238
|
+
try {
|
|
239
|
+
const {stdout} = await runCommand('git', ['diff', '--numstat', 'HEAD', '--'], {
|
|
240
|
+
capture: true,
|
|
241
|
+
cwd: rootDir
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return stdout
|
|
245
|
+
.split(/\r?\n/)
|
|
246
|
+
.map((line) => line.trim())
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
.reduce((map, line) => {
|
|
249
|
+
const [addedRaw, deletedRaw, filePath] = line.split('\t')
|
|
250
|
+
if (!filePath) {
|
|
251
|
+
return map
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const added = Number.parseInt(addedRaw, 10)
|
|
255
|
+
const deleted = Number.parseInt(deletedRaw, 10)
|
|
256
|
+
map.set(filePath, {
|
|
257
|
+
added: Number.isFinite(added) ? added : 0,
|
|
258
|
+
deleted: Number.isFinite(deleted) ? deleted : 0
|
|
259
|
+
})
|
|
260
|
+
return map
|
|
261
|
+
}, new Map())
|
|
262
|
+
} catch {
|
|
263
|
+
return new Map()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function buildCommitMessageContext(rootDir, {
|
|
268
|
+
runCommand,
|
|
269
|
+
statusEntries = []
|
|
270
|
+
} = {}) {
|
|
271
|
+
const changeCountsByPath = await collectDiffNumstat(rootDir, {runCommand})
|
|
272
|
+
return statusEntries.map((entry) => `- ${summarizeWorkingTreeEntry(entry, {changeCountsByPath})}`).join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function suggestReleaseCommitMessage(rootDir = process.cwd(), {
|
|
276
|
+
runCommand,
|
|
277
|
+
commandExistsImpl = commandExists,
|
|
278
|
+
logStep,
|
|
279
|
+
logWarning,
|
|
280
|
+
statusEntries = []
|
|
281
|
+
} = {}) {
|
|
282
|
+
if (!commandExistsImpl('codex')) {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let tempDir = null
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'zephyr-release-commit-'))
|
|
290
|
+
const outputPath = path.join(tempDir, 'codex-last-message.txt')
|
|
291
|
+
const commitContext = await buildCommitMessageContext(rootDir, {
|
|
292
|
+
runCommand,
|
|
293
|
+
statusEntries
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
logStep?.('Generating a suggested commit message with Codex...')
|
|
297
|
+
|
|
298
|
+
await runCommand('codex', [
|
|
299
|
+
'exec',
|
|
300
|
+
'--ephemeral',
|
|
301
|
+
'--model',
|
|
302
|
+
'gpt-5.4-mini',
|
|
303
|
+
'--sandbox',
|
|
304
|
+
'read-only',
|
|
305
|
+
'--skip-git-repo-check',
|
|
306
|
+
'--output-last-message',
|
|
307
|
+
outputPath,
|
|
308
|
+
[
|
|
309
|
+
'Write exactly one short conventional commit message for these pending changes.',
|
|
310
|
+
'Use the exact format "<type>: <subject>" with no scope, no exclamation mark, and no extra text.',
|
|
311
|
+
'Choose the most appropriate type from: fix, feat, chore, docs, refactor, test, style, perf, build, ci, revert.',
|
|
312
|
+
'Make the subject specific enough to describe the actual behavior or workflow change, not just that files changed.',
|
|
313
|
+
'Pending change summary:',
|
|
314
|
+
commitContext || '- changed files present'
|
|
315
|
+
].join('\n\n')
|
|
316
|
+
], {
|
|
317
|
+
capture: true,
|
|
318
|
+
cwd: rootDir
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const rawMessage = await readFile(outputPath, 'utf8')
|
|
322
|
+
return sanitizeSuggestedCommitMessage(rawMessage)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
logWarning?.(`Codex could not suggest a commit message: ${error.message}`)
|
|
325
|
+
return null
|
|
326
|
+
} finally {
|
|
327
|
+
if (tempDir) {
|
|
328
|
+
await rm(tempDir, {recursive: true, force: true}).catch(() => {})
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/release/shared.mjs
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import inquirer from 'inquirer'
|
|
2
2
|
import process from 'node:process'
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import {validateLocalDependencies} from '../dependency-scanner.mjs'
|
|
5
|
+
import {runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase} from '../utils/command.mjs'
|
|
6
|
+
import {gitCommitArgs} from '../utils/git-hooks.mjs'
|
|
6
7
|
import {
|
|
7
8
|
ensureUpToDateWithUpstream,
|
|
8
9
|
getCurrentBranch,
|
|
9
10
|
getUpstreamRef
|
|
10
11
|
} from '../utils/git.mjs'
|
|
12
|
+
import {
|
|
13
|
+
buildFallbackCommitMessage,
|
|
14
|
+
formatWorkingTreePreview,
|
|
15
|
+
parseWorkingTreeEntries,
|
|
16
|
+
parseWorkingTreeStatus,
|
|
17
|
+
suggestReleaseCommitMessage
|
|
18
|
+
} from './commit-message.mjs'
|
|
11
19
|
|
|
12
20
|
const RELEASE_TYPES = new Set([
|
|
13
21
|
'major',
|
|
@@ -18,6 +26,8 @@ const RELEASE_TYPES = new Set([
|
|
|
18
26
|
'prepatch',
|
|
19
27
|
'prerelease'
|
|
20
28
|
])
|
|
29
|
+
const DIRTY_WORKING_TREE_MESSAGE = 'Working tree has uncommitted changes. Commit or stash them before releasing.'
|
|
30
|
+
const DIRTY_WORKING_TREE_CANCELLED_MESSAGE = 'Release cancelled: pending changes were not committed.'
|
|
21
31
|
|
|
22
32
|
function flagToKey(flag) {
|
|
23
33
|
return flag
|
|
@@ -60,7 +70,7 @@ export function parseReleaseArgs({
|
|
|
60
70
|
booleanFlags.map((flag) => [flagToKey(flag), presentFlags.has(flag)])
|
|
61
71
|
)
|
|
62
72
|
|
|
63
|
-
return {
|
|
73
|
+
return {releaseType, ...parsedFlags}
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
export async function runReleaseCommand(command, args, {
|
|
@@ -70,32 +80,103 @@ export async function runReleaseCommand(command, args, {
|
|
|
70
80
|
runCommandCaptureImpl = runCommandCaptureBase
|
|
71
81
|
} = {}) {
|
|
72
82
|
if (capture) {
|
|
73
|
-
const captured = await runCommandCaptureImpl(command, args, {
|
|
83
|
+
const captured = await runCommandCaptureImpl(command, args, {cwd})
|
|
74
84
|
|
|
75
85
|
if (typeof captured === 'string') {
|
|
76
|
-
return {
|
|
86
|
+
return {stdout: captured.trim(), stderr: ''}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const stdout = captured?.stdout ?? ''
|
|
80
90
|
const stderr = captured?.stderr ?? ''
|
|
81
|
-
return {
|
|
91
|
+
return {stdout: stdout.trim(), stderr: stderr.trim()}
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
await runCommandImpl(command, args, {
|
|
94
|
+
await runCommandImpl(command, args, {cwd})
|
|
85
95
|
return undefined
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
|
|
89
|
-
runCommand = runReleaseCommand
|
|
99
|
+
runCommand = runReleaseCommand,
|
|
100
|
+
runPrompt,
|
|
101
|
+
logStep,
|
|
102
|
+
logSuccess,
|
|
103
|
+
logWarning,
|
|
104
|
+
interactive = true,
|
|
105
|
+
skipGitHooks = false,
|
|
106
|
+
suggestCommitMessage = suggestReleaseCommitMessage
|
|
90
107
|
} = {}) {
|
|
91
|
-
const {
|
|
108
|
+
const {stdout} = await runCommand('git', ['status', '--porcelain'], {
|
|
92
109
|
capture: true,
|
|
93
110
|
cwd: rootDir
|
|
94
111
|
})
|
|
112
|
+
const statusEntries = parseWorkingTreeEntries(stdout)
|
|
95
113
|
|
|
96
|
-
if (
|
|
97
|
-
|
|
114
|
+
if (statusEntries.length === 0) {
|
|
115
|
+
return
|
|
98
116
|
}
|
|
117
|
+
|
|
118
|
+
if (!interactive || typeof runPrompt !== 'function') {
|
|
119
|
+
throw new Error(DIRTY_WORKING_TREE_MESSAGE)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const suggestedCommitMessage = await suggestCommitMessage(rootDir, {
|
|
123
|
+
runCommand,
|
|
124
|
+
logStep,
|
|
125
|
+
logWarning,
|
|
126
|
+
statusEntries
|
|
127
|
+
}) ?? buildFallbackCommitMessage(statusEntries)
|
|
128
|
+
|
|
129
|
+
const changeLabel = statusEntries.length === 1 ? 'change' : 'changes'
|
|
130
|
+
const {shouldCommitPendingChanges} = await runPrompt([
|
|
131
|
+
{
|
|
132
|
+
type: 'confirm',
|
|
133
|
+
name: 'shouldCommitPendingChanges',
|
|
134
|
+
message:
|
|
135
|
+
`Pending ${changeLabel} detected before release:\n\n` +
|
|
136
|
+
`${formatWorkingTreePreview(statusEntries)}\n\n` +
|
|
137
|
+
'Stage and commit all current changes before continuing?',
|
|
138
|
+
default: true
|
|
139
|
+
}
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
if (!shouldCommitPendingChanges) {
|
|
143
|
+
throw new Error(DIRTY_WORKING_TREE_CANCELLED_MESSAGE)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const {commitMessage} = await runPrompt([
|
|
147
|
+
{
|
|
148
|
+
type: 'input',
|
|
149
|
+
name: 'commitMessage',
|
|
150
|
+
message: 'Commit message for pending release changes',
|
|
151
|
+
default: suggestedCommitMessage,
|
|
152
|
+
validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
|
|
153
|
+
}
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
const message = commitMessage.trim()
|
|
157
|
+
|
|
158
|
+
logStep?.('Staging all pending changes before release...')
|
|
159
|
+
await runCommand('git', ['add', '-A'], {
|
|
160
|
+
capture: true,
|
|
161
|
+
cwd: rootDir
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
logStep?.('Committing pending changes before release...')
|
|
165
|
+
await runCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}), {
|
|
166
|
+
capture: true,
|
|
167
|
+
cwd: rootDir
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const {stdout: finalStatus} = await runCommand('git', ['status', '--porcelain'], {
|
|
171
|
+
capture: true,
|
|
172
|
+
cwd: rootDir
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
if (parseWorkingTreeStatus(finalStatus).length > 0) {
|
|
176
|
+
throw new Error('Working tree still has uncommitted changes after the release commit. Commit or stash them before releasing.')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
logSuccess?.(`Committed pending changes with "${message}".`)
|
|
99
180
|
}
|
|
100
181
|
|
|
101
182
|
export async function validateReleaseDependencies(rootDir = process.cwd(), {
|
|
@@ -119,7 +200,7 @@ export async function ensureReleaseBranchReady({
|
|
|
119
200
|
logStep,
|
|
120
201
|
logWarning
|
|
121
202
|
} = {}) {
|
|
122
|
-
const branch = await getCurrentBranchImpl(rootDir, {
|
|
203
|
+
const branch = await getCurrentBranchImpl(rootDir, {method: branchMethod})
|
|
123
204
|
|
|
124
205
|
if (!branch) {
|
|
125
206
|
throw new Error('Unable to determine current branch.')
|
|
@@ -128,7 +209,9 @@ export async function ensureReleaseBranchReady({
|
|
|
128
209
|
logStep?.(`Current branch: ${branch}`)
|
|
129
210
|
|
|
130
211
|
const upstreamRef = await getUpstreamRefImpl(rootDir)
|
|
131
|
-
await ensureUpToDateWithUpstreamImpl({
|
|
212
|
+
await ensureUpToDateWithUpstreamImpl({branch, upstreamRef, rootDir, logStep, logWarning})
|
|
132
213
|
|
|
133
|
-
return {
|
|
214
|
+
return {branch, upstreamRef}
|
|
134
215
|
}
|
|
216
|
+
|
|
217
|
+
export {suggestReleaseCommitMessage}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
1
2
|
import chalk from 'chalk'
|
|
2
3
|
import inquirer from 'inquirer'
|
|
3
4
|
import {NodeSSH} from 'node-ssh'
|
|
@@ -12,6 +13,7 @@ export function createAppContext({
|
|
|
12
13
|
chalkInstance = chalk,
|
|
13
14
|
inquirerInstance = inquirer,
|
|
14
15
|
NodeSSHClass = NodeSSH,
|
|
16
|
+
processInstance = process,
|
|
15
17
|
runCommandImpl = runCommandBase,
|
|
16
18
|
runCommandCaptureImpl = runCommandCaptureBase,
|
|
17
19
|
executionMode = {}
|
|
@@ -38,6 +40,7 @@ export function createAppContext({
|
|
|
38
40
|
emitEvent,
|
|
39
41
|
workflow: normalizedExecutionMode.workflow
|
|
40
42
|
})
|
|
43
|
+
const hasInteractiveTerminal = Boolean(processInstance?.stdin?.isTTY && processInstance?.stdout?.isTTY)
|
|
41
44
|
const createSshClient = createSshClientFactory({NodeSSH: NodeSSHClass})
|
|
42
45
|
const {runCommand, runCommandCapture} = createLocalCommandRunners({
|
|
43
46
|
runCommandBase: runCommandImpl,
|
|
@@ -59,6 +62,7 @@ export function createAppContext({
|
|
|
59
62
|
runCommand: runCommandWithMode,
|
|
60
63
|
runCommandCapture,
|
|
61
64
|
emitEvent,
|
|
65
|
+
hasInteractiveTerminal,
|
|
62
66
|
executionMode: normalizedExecutionMode
|
|
63
67
|
}
|
|
64
68
|
}
|