@wyxos/zephyr 0.9.8 → 0.9.10
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/package.json +1 -1
- package/src/application/release/release-node-package.mjs +10 -1
- package/src/application/release/release-packagist-package.mjs +9 -0
- package/src/release/github-actions.mjs +197 -0
- package/src/runtime/app-context.mjs +1 -1
- package/src/runtime/ssh-client.mjs +59 -4
- package/src/ssh/ssh.mjs +1 -2
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
runReleaseCommand,
|
|
13
13
|
validateReleaseDependencies
|
|
14
14
|
} from '../../release/shared.mjs'
|
|
15
|
+
import {waitForGitHubReleaseWorkflows} from '../../release/github-actions.mjs'
|
|
15
16
|
import {resolveReleaseType} from '../../release/release-type.mjs'
|
|
16
17
|
import {gitCommitArgs, gitPushArgs, npmVersionArgs} from '../../utils/git-hooks.mjs'
|
|
17
18
|
|
|
@@ -469,10 +470,18 @@ export async function releaseNodePackage({
|
|
|
469
470
|
if (skipVersioning) {
|
|
470
471
|
await createReleaseTag(updatedPkg.version, rootDir, {logStep, runCommand})
|
|
471
472
|
}
|
|
473
|
+
const releasePushStartedAt = new Date()
|
|
472
474
|
await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
|
|
475
|
+
logStep?.('Publishing will be handled by GitHub Actions via trusted publishing.')
|
|
476
|
+
await waitForGitHubReleaseWorkflows(rootDir, {
|
|
477
|
+
runCommand,
|
|
478
|
+
logStep,
|
|
479
|
+
logSuccess,
|
|
480
|
+
logWarning,
|
|
481
|
+
pushStartedAt: releasePushStartedAt
|
|
482
|
+
})
|
|
473
483
|
await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
|
|
474
484
|
|
|
475
|
-
logStep?.('Publishing will be handled by GitHub Actions via trusted publishing.')
|
|
476
485
|
logSuccess?.(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
|
|
477
486
|
return updatedPkg
|
|
478
487
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
runReleaseCommand,
|
|
12
12
|
validateReleaseDependencies
|
|
13
13
|
} from '../../release/shared.mjs'
|
|
14
|
+
import {waitForGitHubReleaseWorkflows} from '../../release/github-actions.mjs'
|
|
14
15
|
import {resolveReleaseType} from '../../release/release-type.mjs'
|
|
15
16
|
import {gitCommitArgs, gitPushArgs} from '../../utils/git-hooks.mjs'
|
|
16
17
|
|
|
@@ -322,7 +323,15 @@ export async function releasePackagistPackage({
|
|
|
322
323
|
if (skipVersioning) {
|
|
323
324
|
await createReleaseTag(updatedComposer.version, rootDir, {logStep, runCommand})
|
|
324
325
|
}
|
|
326
|
+
const releasePushStartedAt = new Date()
|
|
325
327
|
await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
|
|
328
|
+
await waitForGitHubReleaseWorkflows(rootDir, {
|
|
329
|
+
runCommand,
|
|
330
|
+
logStep,
|
|
331
|
+
logSuccess,
|
|
332
|
+
logWarning,
|
|
333
|
+
pushStartedAt: releasePushStartedAt
|
|
334
|
+
})
|
|
326
335
|
|
|
327
336
|
logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
|
|
328
337
|
logStep?.('Note: Packagist will automatically detect the new git tag and update the package.')
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {readdir} from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import {runReleaseCommand} from './shared.mjs'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS = 60_000
|
|
8
|
+
const DEFAULT_WORKFLOW_POLL_INTERVAL_MS = 3_000
|
|
9
|
+
const RUN_CREATED_AT_TOLERANCE_MS = 5_000
|
|
10
|
+
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
setTimeout(resolve, ms)
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function describeError(error) {
|
|
18
|
+
const stderr = String(error?.stderr ?? '').trim()
|
|
19
|
+
if (stderr) {
|
|
20
|
+
return stderr
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const message = String(error?.message ?? '').trim()
|
|
24
|
+
if (message) {
|
|
25
|
+
return message
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return String(error ?? 'unknown error')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function hasGitHubWorkflowFiles(rootDir) {
|
|
32
|
+
const workflowDir = path.join(rootDir, '.github', 'workflows')
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const entries = await readdir(workflowDir, {withFileTypes: true})
|
|
36
|
+
|
|
37
|
+
return entries.some((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name))
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseWorkflowRuns(stdout) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(stdout || '[]')
|
|
46
|
+
|
|
47
|
+
return Array.isArray(parsed) ? parsed : []
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRunForCurrentPush(run, pushStartedAt) {
|
|
54
|
+
if (!pushStartedAt || !run?.createdAt) {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const runCreatedAt = Date.parse(run.createdAt)
|
|
59
|
+
const startedAt = pushStartedAt instanceof Date
|
|
60
|
+
? pushStartedAt.getTime()
|
|
61
|
+
: Date.parse(pushStartedAt)
|
|
62
|
+
|
|
63
|
+
if (!Number.isFinite(runCreatedAt) || !Number.isFinite(startedAt)) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return runCreatedAt >= startedAt - RUN_CREATED_AT_TOLERANCE_MS
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function workflowLabel(run) {
|
|
71
|
+
const workflowName = run?.workflowName || run?.name || 'GitHub Actions workflow'
|
|
72
|
+
const databaseId = run?.databaseId ? `#${run.databaseId}` : ''
|
|
73
|
+
|
|
74
|
+
return `${workflowName}${databaseId ? ` ${databaseId}` : ''}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function findCurrentPushRuns(rootDir, {
|
|
78
|
+
runCommand,
|
|
79
|
+
headSha,
|
|
80
|
+
pushStartedAt,
|
|
81
|
+
timeoutMs,
|
|
82
|
+
pollIntervalMs,
|
|
83
|
+
sleepImpl
|
|
84
|
+
}) {
|
|
85
|
+
const deadline = Date.now() + timeoutMs
|
|
86
|
+
let shouldPoll = true
|
|
87
|
+
|
|
88
|
+
while (shouldPoll) {
|
|
89
|
+
const {stdout} = await runCommand('gh', [
|
|
90
|
+
'run',
|
|
91
|
+
'list',
|
|
92
|
+
'--commit',
|
|
93
|
+
headSha,
|
|
94
|
+
'--event',
|
|
95
|
+
'push',
|
|
96
|
+
'--json',
|
|
97
|
+
'databaseId,status,conclusion,workflowName,createdAt,url',
|
|
98
|
+
'--limit',
|
|
99
|
+
'20'
|
|
100
|
+
], {
|
|
101
|
+
capture: true,
|
|
102
|
+
cwd: rootDir
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const runs = parseWorkflowRuns(stdout)
|
|
106
|
+
.filter((run) => run?.databaseId)
|
|
107
|
+
.filter((run) => isRunForCurrentPush(run, pushStartedAt))
|
|
108
|
+
|
|
109
|
+
if (runs.length > 0) {
|
|
110
|
+
return runs
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
shouldPoll = Date.now() < deadline
|
|
114
|
+
|
|
115
|
+
if (shouldPoll) {
|
|
116
|
+
await sleepImpl(pollIntervalMs)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function waitForGitHubReleaseWorkflows(rootDir = process.cwd(), {
|
|
124
|
+
runCommand = runReleaseCommand,
|
|
125
|
+
logStep,
|
|
126
|
+
logSuccess,
|
|
127
|
+
logWarning,
|
|
128
|
+
pushStartedAt = new Date(),
|
|
129
|
+
timeoutMs = DEFAULT_WORKFLOW_WAIT_TIMEOUT_MS,
|
|
130
|
+
pollIntervalMs = DEFAULT_WORKFLOW_POLL_INTERVAL_MS,
|
|
131
|
+
sleepImpl = sleep
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (!await hasGitHubWorkflowFiles(rootDir)) {
|
|
134
|
+
return {status: 'skipped', reason: 'no-workflows', runs: []}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await runCommand('gh', ['--version'], {capture: true, cwd: rootDir})
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logWarning?.(`GitHub Actions workflow monitoring skipped because GitHub CLI is unavailable: ${describeError(error)}`)
|
|
141
|
+
return {status: 'skipped', reason: 'gh-unavailable', runs: []}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let headSha
|
|
145
|
+
try {
|
|
146
|
+
const result = await runCommand('git', ['rev-parse', 'HEAD'], {capture: true, cwd: rootDir})
|
|
147
|
+
headSha = result.stdout.trim()
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logWarning?.(`GitHub Actions workflow monitoring skipped because the release commit could not be resolved: ${describeError(error)}`)
|
|
150
|
+
return {status: 'skipped', reason: 'head-unavailable', runs: []}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!headSha) {
|
|
154
|
+
logWarning?.('GitHub Actions workflow monitoring skipped because the release commit could not be resolved.')
|
|
155
|
+
return {status: 'skipped', reason: 'head-unavailable', runs: []}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
logStep?.('Checking for GitHub Actions workflows triggered by the release push...')
|
|
159
|
+
|
|
160
|
+
let runs = []
|
|
161
|
+
try {
|
|
162
|
+
runs = await findCurrentPushRuns(rootDir, {
|
|
163
|
+
runCommand,
|
|
164
|
+
headSha,
|
|
165
|
+
pushStartedAt,
|
|
166
|
+
timeoutMs,
|
|
167
|
+
pollIntervalMs,
|
|
168
|
+
sleepImpl
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
logWarning?.(`GitHub Actions workflow monitoring skipped because workflow runs could not be listed: ${describeError(error)}`)
|
|
172
|
+
return {status: 'skipped', reason: 'list-failed', runs: []}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (runs.length === 0) {
|
|
176
|
+
logWarning?.('GitHub Actions workflow monitoring skipped because no workflow run appeared for the release push.')
|
|
177
|
+
return {status: 'skipped', reason: 'no-runs', runs: []}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const run of runs) {
|
|
181
|
+
const label = workflowLabel(run)
|
|
182
|
+
logStep?.(`Watching GitHub Actions workflow ${label}...`)
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await runCommand('gh', ['run', 'watch', String(run.databaseId), '--exit-status', '--compact'], {
|
|
186
|
+
cwd: rootDir
|
|
187
|
+
})
|
|
188
|
+
} catch (_error) {
|
|
189
|
+
const suffix = run.url ? ` ${run.url}` : ''
|
|
190
|
+
throw new Error(`GitHub Actions workflow ${label} failed or could not be watched.${suffix}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
logSuccess?.(`GitHub Actions workflow ${label} completed successfully.`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {status: 'watched', reason: null, runs}
|
|
197
|
+
}
|
|
@@ -54,7 +54,7 @@ export function createAppContext({
|
|
|
54
54
|
workflow: normalizedExecutionMode.workflow
|
|
55
55
|
})
|
|
56
56
|
const hasInteractiveTerminal = Boolean(processInstance?.stdin?.isTTY && processInstance?.stdout?.isTTY)
|
|
57
|
-
const createSshClient = createSshClientFactory({NodeSSH: NodeSSHClass})
|
|
57
|
+
const createSshClient = createSshClientFactory({NodeSSH: NodeSSHClass, logWarning})
|
|
58
58
|
const {runCommand, runCommandCapture} = createLocalCommandRunners({
|
|
59
59
|
runCommandBase: runCommandImpl,
|
|
60
60
|
runCommandCaptureBase: runCommandCaptureImpl
|
|
@@ -1,14 +1,69 @@
|
|
|
1
|
-
|
|
1
|
+
const connectionErrorHandlerSymbol = Symbol('zephyrSshConnectionErrorHandler')
|
|
2
|
+
const connectWrapperSymbol = Symbol('zephyrSshConnectWrapper')
|
|
3
|
+
|
|
4
|
+
function getErrorMessage(error) {
|
|
5
|
+
if (error instanceof Error && error.message) {
|
|
6
|
+
return error.message
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return String(error ?? 'Unknown SSH connection error')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function attachSshConnectionErrorHandler(ssh, { logWarning } = {}) {
|
|
13
|
+
const connection = ssh?.connection
|
|
14
|
+
|
|
15
|
+
if (!connection || typeof connection.on !== 'function') {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (connection[connectionErrorHandlerSymbol]) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let hasLogged = false
|
|
24
|
+
const handler = (error) => {
|
|
25
|
+
if (hasLogged) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
hasLogged = true
|
|
30
|
+
logWarning?.(`SSH connection emitted a background error after connect: ${getErrorMessage(error)}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connection.on('error', handler)
|
|
34
|
+
connection[connectionErrorHandlerSymbol] = handler
|
|
35
|
+
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function wrapSshConnect(ssh, { logWarning } = {}) {
|
|
40
|
+
if (!ssh || typeof ssh.connect !== 'function' || ssh[connectWrapperSymbol]) {
|
|
41
|
+
return ssh
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const originalConnect = ssh.connect
|
|
45
|
+
|
|
46
|
+
ssh.connect = async function connectWithBackgroundErrorHandler(...args) {
|
|
47
|
+
const result = await originalConnect.apply(this, args)
|
|
48
|
+
attachSshConnectionErrorHandler(this, { logWarning })
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
ssh[connectWrapperSymbol] = true
|
|
53
|
+
|
|
54
|
+
return ssh
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createSshClientFactory({ NodeSSH, logWarning }) {
|
|
2
58
|
if (!NodeSSH) {
|
|
3
59
|
throw new Error('createSshClientFactory requires NodeSSH')
|
|
4
60
|
}
|
|
5
61
|
|
|
6
62
|
return function createSshClient() {
|
|
7
63
|
if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
|
|
8
|
-
return globalThis.__zephyrSSHFactory()
|
|
64
|
+
return wrapSshConnect(globalThis.__zephyrSSHFactory(), { logWarning })
|
|
9
65
|
}
|
|
10
66
|
|
|
11
|
-
return new NodeSSH()
|
|
67
|
+
return wrapSshConnect(new NodeSSH(), { logWarning })
|
|
12
68
|
}
|
|
13
69
|
}
|
|
14
|
-
|
package/src/ssh/ssh.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createSshClientFactory } from '../runtime/ssh-client.mjs'
|
|
|
11
11
|
|
|
12
12
|
const { logProcessing, logSuccess, logWarning, logError } = createChalkLogger(chalk)
|
|
13
13
|
|
|
14
|
-
const createSshClient = createSshClientFactory({ NodeSSH })
|
|
14
|
+
const createSshClient = createSshClientFactory({ NodeSSH, logWarning })
|
|
15
15
|
|
|
16
16
|
function normalizeRemotePath(value) {
|
|
17
17
|
if (value == null) return value
|
|
@@ -136,4 +136,3 @@ export async function deleteRemoteFile(ssh, remotePath, remoteCwd) {
|
|
|
136
136
|
logSuccess(`Deleted remote file: ${absoluteRemotePath}`)
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
|