@wyxos/zephyr 0.2.31 → 0.3.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/README.md +55 -2
- package/bin/zephyr.mjs +3 -1
- package/package.json +7 -2
- package/src/application/configuration/app-details.mjs +89 -0
- package/src/application/configuration/app-selection.mjs +87 -0
- package/src/application/configuration/preset-selection.mjs +59 -0
- package/src/application/configuration/select-deployment-target.mjs +165 -0
- package/src/application/configuration/server-selection.mjs +87 -0
- package/src/application/configuration/service.mjs +109 -0
- package/src/application/deploy/build-remote-deployment-plan.mjs +174 -0
- package/src/application/deploy/bump-local-package-version.mjs +81 -0
- package/src/application/deploy/execute-remote-deployment-plan.mjs +61 -0
- package/src/{utils/task-planner.mjs → application/deploy/plan-laravel-deployment-tasks.mjs} +5 -4
- package/src/application/deploy/prepare-local-deployment.mjs +52 -0
- package/src/application/deploy/resolve-local-deployment-context.mjs +17 -0
- package/src/application/deploy/resolve-pending-snapshot.mjs +45 -0
- package/src/application/deploy/run-deployment.mjs +147 -0
- package/src/application/deploy/run-local-deployment-checks.mjs +80 -0
- package/src/application/release/release-node-package.mjs +340 -0
- package/src/application/release/release-packagist-package.mjs +223 -0
- package/src/config/project.mjs +13 -0
- package/src/deploy/local-repo.mjs +187 -67
- package/src/deploy/remote-exec.mjs +2 -3
- package/src/index.mjs +27 -85
- package/src/main.mjs +78 -641
- package/src/release/shared.mjs +104 -0
- package/src/release-node.mjs +20 -424
- package/src/release-packagist.mjs +20 -291
- package/src/runtime/app-context.mjs +36 -0
- package/src/targets/index.mjs +24 -0
- package/src/utils/output.mjs +41 -16
- package/src/utils/config-flow.mjs +0 -284
- /package/src/{utils/php-version.mjs → infrastructure/php/version.mjs} +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
acquireRemoteLock,
|
|
7
|
+
compareLocksAndPrompt,
|
|
8
|
+
releaseLocalLock,
|
|
9
|
+
releaseRemoteLock
|
|
10
|
+
} from '../../deploy/locks.mjs'
|
|
11
|
+
import {createRemoteExecutor} from '../../deploy/remote-exec.mjs'
|
|
12
|
+
import {resolveSshKeyPath} from '../../ssh/keys.mjs'
|
|
13
|
+
import {cleanupOldLogs, closeLogFile, getLogFilePath, writeToLogFile} from '../../utils/log-file.mjs'
|
|
14
|
+
import {resolveRemotePath} from '../../utils/remote-path.mjs'
|
|
15
|
+
import {buildRemoteDeploymentPlan} from './build-remote-deployment-plan.mjs'
|
|
16
|
+
import {executeRemoteDeploymentPlan} from './execute-remote-deployment-plan.mjs'
|
|
17
|
+
import {prepareLocalDeployment} from './prepare-local-deployment.mjs'
|
|
18
|
+
|
|
19
|
+
async function resolveRemoteHome(ssh, sshUser) {
|
|
20
|
+
const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
|
|
21
|
+
return remoteHomeResult.stdout.trim() || `/home/${sshUser}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runDeployment(config, options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
snapshot = null,
|
|
27
|
+
rootDir = process.cwd(),
|
|
28
|
+
versionArg = null,
|
|
29
|
+
context
|
|
30
|
+
} = options
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
logProcessing,
|
|
34
|
+
logSuccess,
|
|
35
|
+
logWarning,
|
|
36
|
+
logError,
|
|
37
|
+
runPrompt,
|
|
38
|
+
createSshClient,
|
|
39
|
+
runCommand
|
|
40
|
+
} = context
|
|
41
|
+
|
|
42
|
+
await cleanupOldLogs(rootDir)
|
|
43
|
+
|
|
44
|
+
const {requiredPhpVersion} = await prepareLocalDeployment(config, {
|
|
45
|
+
snapshot,
|
|
46
|
+
rootDir,
|
|
47
|
+
versionArg,
|
|
48
|
+
runPrompt,
|
|
49
|
+
runCommand,
|
|
50
|
+
runCommandCapture: context.runCommandCapture,
|
|
51
|
+
logProcessing,
|
|
52
|
+
logSuccess,
|
|
53
|
+
logWarning
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const ssh = createSshClient()
|
|
57
|
+
const sshUser = config.sshUser || os.userInfo().username
|
|
58
|
+
const privateKeyPath = await resolveSshKeyPath(config.sshKey)
|
|
59
|
+
const privateKey = await fs.readFile(privateKeyPath, 'utf8')
|
|
60
|
+
let remoteCwd = null
|
|
61
|
+
|
|
62
|
+
logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
|
|
63
|
+
|
|
64
|
+
let lockAcquired = false
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await ssh.connect({
|
|
68
|
+
host: config.serverIp,
|
|
69
|
+
username: sshUser,
|
|
70
|
+
privateKey
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const remoteHome = await resolveRemoteHome(ssh, sshUser)
|
|
74
|
+
remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
|
|
75
|
+
|
|
76
|
+
logProcessing('Connection established. Acquiring deployment lock on server...')
|
|
77
|
+
await acquireRemoteLock(ssh, remoteCwd, rootDir, {runPrompt, logWarning})
|
|
78
|
+
lockAcquired = true
|
|
79
|
+
logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
|
|
80
|
+
|
|
81
|
+
const executeRemote = createRemoteExecutor({
|
|
82
|
+
ssh,
|
|
83
|
+
rootDir,
|
|
84
|
+
remoteCwd,
|
|
85
|
+
writeToLogFile,
|
|
86
|
+
logProcessing,
|
|
87
|
+
logSuccess,
|
|
88
|
+
logError
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const remotePlan = await buildRemoteDeploymentPlan({
|
|
92
|
+
config,
|
|
93
|
+
snapshot,
|
|
94
|
+
rootDir,
|
|
95
|
+
requiredPhpVersion,
|
|
96
|
+
ssh,
|
|
97
|
+
remoteCwd,
|
|
98
|
+
executeRemote,
|
|
99
|
+
logProcessing,
|
|
100
|
+
logSuccess,
|
|
101
|
+
logWarning
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await executeRemoteDeploymentPlan({
|
|
105
|
+
rootDir,
|
|
106
|
+
executeRemote,
|
|
107
|
+
steps: remotePlan.steps,
|
|
108
|
+
usefulSteps: remotePlan.usefulSteps,
|
|
109
|
+
pendingSnapshot: remotePlan.pendingSnapshot,
|
|
110
|
+
logProcessing
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
logSuccess('\nDeployment commands completed successfully.')
|
|
114
|
+
|
|
115
|
+
const logPath = await getLogFilePath(rootDir)
|
|
116
|
+
logSuccess(`\nAll task output has been logged to: ${logPath}`)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const logPath = await getLogFilePath(rootDir).catch(() => null)
|
|
119
|
+
if (logPath) {
|
|
120
|
+
logError(`\nTask output has been logged to: ${logPath}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (lockAcquired && ssh && remoteCwd) {
|
|
124
|
+
try {
|
|
125
|
+
await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore lock comparison errors during error handling
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new Error(`Deployment failed: ${error.message}`)
|
|
132
|
+
} finally {
|
|
133
|
+
if (lockAcquired && ssh && remoteCwd) {
|
|
134
|
+
try {
|
|
135
|
+
await releaseRemoteLock(ssh, remoteCwd, {logWarning})
|
|
136
|
+
await releaseLocalLock(rootDir, {logWarning})
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logWarning(`Failed to release lock: ${error.message}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await closeLogFile()
|
|
143
|
+
if (ssh) {
|
|
144
|
+
ssh.dispose()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as localRepo from '../../deploy/local-repo.mjs'
|
|
2
|
+
import * as preflight from '../../deploy/preflight.mjs'
|
|
3
|
+
import {commandExists} from '../../utils/command.mjs'
|
|
4
|
+
|
|
5
|
+
async function getGitStatus(rootDir, {runCommandCapture} = {}) {
|
|
6
|
+
return await localRepo.getGitStatus(rootDir, {runCommandCapture})
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function hasUncommittedChanges(rootDir, {runCommandCapture} = {}) {
|
|
10
|
+
return await localRepo.hasUncommittedChanges(rootDir, {
|
|
11
|
+
getGitStatus: (dir) => getGitStatus(dir, {runCommandCapture})
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, logWarning} = {}) {
|
|
16
|
+
if (!commandExists('php')) {
|
|
17
|
+
logWarning?.(
|
|
18
|
+
'PHP is not available in PATH. Skipping local Laravel tests.\n' +
|
|
19
|
+
' To run tests locally, ensure PHP is installed and added to your PATH.\n' +
|
|
20
|
+
' On Windows with Laravel Herd, you may need to add Herd\'s PHP to your system PATH.'
|
|
21
|
+
)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
logProcessing?.('Running Laravel tests locally...')
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await runCommand('php', ['artisan', 'test', '--compact'], {cwd: rootDir})
|
|
29
|
+
logSuccess?.('Local tests passed.')
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'Failed to run Laravel tests: PHP executable not found.\n' +
|
|
34
|
+
'Make sure PHP is installed and available in your PATH.'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw new Error(`Local tests failed. Fix test failures before deploying.\n${error.message}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runLocalDeploymentChecks({
|
|
43
|
+
rootDir,
|
|
44
|
+
isLaravel,
|
|
45
|
+
hasHook,
|
|
46
|
+
runCommand,
|
|
47
|
+
runCommandCapture,
|
|
48
|
+
logProcessing,
|
|
49
|
+
logSuccess,
|
|
50
|
+
logWarning
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (hasHook) {
|
|
53
|
+
logProcessing?.('Pre-push git hook detected. Skipping local linting and test execution.')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lintRan = await preflight.runLinting(rootDir, {
|
|
58
|
+
runCommand,
|
|
59
|
+
logProcessing,
|
|
60
|
+
logSuccess,
|
|
61
|
+
logWarning,
|
|
62
|
+
commandExists
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (lintRan) {
|
|
66
|
+
const hasChanges = await hasUncommittedChanges(rootDir, {runCommandCapture})
|
|
67
|
+
if (hasChanges) {
|
|
68
|
+
await preflight.commitLintingChanges(rootDir, {
|
|
69
|
+
getGitStatus: (dir) => getGitStatus(dir, {runCommandCapture}),
|
|
70
|
+
runCommand,
|
|
71
|
+
logProcessing,
|
|
72
|
+
logSuccess
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isLaravel) {
|
|
78
|
+
await runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, logWarning})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import {join} from 'node:path'
|
|
2
|
+
import {readFile} from 'node:fs/promises'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
import {writeStderr} from '../../utils/output.mjs'
|
|
8
|
+
import {
|
|
9
|
+
ensureCleanWorkingTree,
|
|
10
|
+
ensureReleaseBranchReady,
|
|
11
|
+
runReleaseCommand as runCommand,
|
|
12
|
+
validateReleaseDependencies
|
|
13
|
+
} from '../../release/shared.mjs'
|
|
14
|
+
|
|
15
|
+
async function readPackage(rootDir = process.cwd()) {
|
|
16
|
+
const packagePath = join(rootDir, 'package.json')
|
|
17
|
+
const raw = await readFile(packagePath, 'utf8')
|
|
18
|
+
return JSON.parse(raw)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasScript(pkg, scriptName) {
|
|
22
|
+
return pkg?.scripts?.[scriptName] !== undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runLint(skipLint, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
|
|
26
|
+
if (skipLint) {
|
|
27
|
+
logWarning?.('Skipping lint because --skip-lint flag was provided.')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!hasScript(pkg, 'lint')) {
|
|
32
|
+
logStep?.('Skipping lint (no lint script found in package.json).')
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logStep?.('Running lint...')
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await runCommand('npm', ['run', 'lint'], {cwd: rootDir})
|
|
40
|
+
logSuccess?.('Lint passed.')
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.stdout) {
|
|
43
|
+
writeStderr(error.stdout)
|
|
44
|
+
}
|
|
45
|
+
if (error.stderr) {
|
|
46
|
+
writeStderr(error.stderr)
|
|
47
|
+
}
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runTests(skipTests, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
|
|
53
|
+
if (skipTests) {
|
|
54
|
+
logWarning?.('Skipping tests because --skip-tests flag was provided.')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!hasScript(pkg, 'test:run') && !hasScript(pkg, 'test')) {
|
|
59
|
+
logStep?.('Skipping tests (no test or test:run script found in package.json).')
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
logStep?.('Running test suite...')
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const testRunScript = pkg?.scripts?.['test:run'] ?? ''
|
|
67
|
+
const testScript = pkg?.scripts?.test ?? ''
|
|
68
|
+
const usesNodeTest = (script) => /\bnode\b.*\s--test\b/.test(script)
|
|
69
|
+
|
|
70
|
+
if (hasScript(pkg, 'test:run')) {
|
|
71
|
+
if (usesNodeTest(testRunScript)) {
|
|
72
|
+
await runCommand('npm', ['run', 'test:run'], {cwd: rootDir})
|
|
73
|
+
} else {
|
|
74
|
+
await runCommand('npm', ['run', 'test:run', '--', '--reporter=dot'], {cwd: rootDir})
|
|
75
|
+
}
|
|
76
|
+
} else if (usesNodeTest(testScript)) {
|
|
77
|
+
await runCommand('npm', ['test'], {cwd: rootDir})
|
|
78
|
+
} else {
|
|
79
|
+
await runCommand('npm', ['test', '--', '--run', '--reporter=dot'], {cwd: rootDir})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
logSuccess?.('Tests passed.')
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.stdout) {
|
|
85
|
+
writeStderr(error.stdout)
|
|
86
|
+
}
|
|
87
|
+
if (error.stderr) {
|
|
88
|
+
writeStderr(error.stderr)
|
|
89
|
+
}
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
|
|
95
|
+
if (skipBuild) {
|
|
96
|
+
logWarning?.('Skipping build because --skip-build flag was provided.')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!hasScript(pkg, 'build')) {
|
|
101
|
+
logStep?.('Skipping build (no build script found in package.json).')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
logStep?.('Building project...')
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await runCommand('npm', ['run', 'build'], {cwd: rootDir})
|
|
109
|
+
logSuccess?.('Build completed.')
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.stdout) {
|
|
112
|
+
writeStderr(error.stdout)
|
|
113
|
+
}
|
|
114
|
+
if (error.stderr) {
|
|
115
|
+
writeStderr(error.stderr)
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runLibBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
|
|
122
|
+
if (skipBuild) {
|
|
123
|
+
logWarning?.('Skipping library build because --skip-build flag was provided.')
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!hasScript(pkg, 'build:lib')) {
|
|
128
|
+
logStep?.('Skipping library build (no build:lib script found in package.json).')
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
logStep?.('Building library...')
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await runCommand('npm', ['run', 'build:lib'], {cwd: rootDir})
|
|
136
|
+
logSuccess?.('Library built.')
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.stdout) {
|
|
139
|
+
writeStderr(error.stdout)
|
|
140
|
+
}
|
|
141
|
+
if (error.stderr) {
|
|
142
|
+
writeStderr(error.stderr)
|
|
143
|
+
}
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const {stdout: statusAfterBuild} = await runCommand('git', ['status', '--porcelain'], {capture: true, cwd: rootDir})
|
|
148
|
+
const hasLibChanges = statusAfterBuild.split('\n').some((line) => {
|
|
149
|
+
const trimmed = line.trim()
|
|
150
|
+
return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (hasLibChanges) {
|
|
154
|
+
logStep?.('Committing lib build artifacts...')
|
|
155
|
+
await runCommand('git', ['add', 'lib/'], {capture: true, cwd: rootDir})
|
|
156
|
+
await runCommand('git', ['commit', '-m', 'chore: build lib artifacts'], {capture: true, cwd: rootDir})
|
|
157
|
+
logSuccess?.('Lib build artifacts committed.')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return hasLibChanges
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSuccess} = {}) {
|
|
164
|
+
logStep?.('Bumping package version...')
|
|
165
|
+
|
|
166
|
+
const {stdout: statusBefore} = await runCommand('git', ['status', '--porcelain'], {capture: true, cwd: rootDir})
|
|
167
|
+
const hasLibChanges = statusBefore.split('\n').some((line) => {
|
|
168
|
+
const trimmed = line.trim()
|
|
169
|
+
return trimmed.includes('lib/') && (trimmed.startsWith('M') || trimmed.startsWith('??') || trimmed.startsWith('A') || trimmed.startsWith('D'))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
if (hasLibChanges) {
|
|
173
|
+
logStep?.('Stashing lib build artifacts...')
|
|
174
|
+
await runCommand('git', ['stash', 'push', '-u', '-m', 'temp: lib build artifacts', 'lib/'], {capture: true, cwd: rootDir})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await runCommand('npm', ['version', releaseType], {capture: true, cwd: rootDir})
|
|
179
|
+
} finally {
|
|
180
|
+
if (hasLibChanges) {
|
|
181
|
+
logStep?.('Restoring lib build artifacts...')
|
|
182
|
+
await runCommand('git', ['stash', 'pop'], {capture: true, cwd: rootDir})
|
|
183
|
+
await runCommand('git', ['add', 'lib/'], {capture: true, cwd: rootDir})
|
|
184
|
+
const {stdout: statusAfter} = await runCommand('git', ['status', '--porcelain'], {capture: true, cwd: rootDir})
|
|
185
|
+
if (statusAfter.includes('lib/')) {
|
|
186
|
+
await runCommand('git', ['commit', '--amend', '--no-edit'], {capture: true, cwd: rootDir})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const pkg = await readPackage(rootDir)
|
|
192
|
+
const commitMessage = `chore: release ${pkg.version}`
|
|
193
|
+
await runCommand('git', ['commit', '--amend', '-m', commitMessage], {capture: true, cwd: rootDir})
|
|
194
|
+
|
|
195
|
+
logSuccess?.(`Version updated to ${pkg.version}.`)
|
|
196
|
+
return pkg
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pushChanges(rootDir = process.cwd(), {logStep, logSuccess} = {}) {
|
|
200
|
+
logStep?.('Pushing commits and tags to origin...')
|
|
201
|
+
try {
|
|
202
|
+
await runCommand('git', ['push', '--follow-tags'], {capture: true, cwd: rootDir})
|
|
203
|
+
logSuccess?.('Git push completed.')
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error.stdout) {
|
|
206
|
+
writeStderr(error.stdout)
|
|
207
|
+
}
|
|
208
|
+
if (error.stderr) {
|
|
209
|
+
writeStderr(error.stderr)
|
|
210
|
+
}
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function extractDomainFromHomepage(homepage) {
|
|
216
|
+
if (!homepage) return null
|
|
217
|
+
try {
|
|
218
|
+
const url = new URL(homepage)
|
|
219
|
+
return url.hostname
|
|
220
|
+
} catch {
|
|
221
|
+
const match = homepage.match(/(?:https?:\/\/)?([^/]+)/)
|
|
222
|
+
return match ? match[1] : null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
|
|
227
|
+
if (skipDeploy) {
|
|
228
|
+
logWarning?.('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const distPath = path.join(rootDir, 'dist')
|
|
233
|
+
const distExists = await fs.promises
|
|
234
|
+
.stat(distPath)
|
|
235
|
+
.then((stats) => stats.isDirectory())
|
|
236
|
+
.catch(() => false)
|
|
237
|
+
|
|
238
|
+
if (!distExists) {
|
|
239
|
+
logStep?.('Skipping GitHub Pages deployment (no dist directory found).')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
logStep?.('Deploying to GitHub Pages...')
|
|
244
|
+
|
|
245
|
+
const cnamePath = path.join(distPath, 'CNAME')
|
|
246
|
+
const homepage =
|
|
247
|
+
pkg &&
|
|
248
|
+
typeof pkg === 'object' &&
|
|
249
|
+
'homepage' in pkg &&
|
|
250
|
+
typeof pkg.homepage === 'string'
|
|
251
|
+
? pkg.homepage
|
|
252
|
+
: null
|
|
253
|
+
|
|
254
|
+
if (homepage) {
|
|
255
|
+
const domain = extractDomainFromHomepage(homepage)
|
|
256
|
+
if (domain) {
|
|
257
|
+
try {
|
|
258
|
+
await fs.promises.mkdir(distPath, {recursive: true})
|
|
259
|
+
await fs.promises.writeFile(cnamePath, domain)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
logWarning?.(`Could not write CNAME file: ${error.message}`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const worktreeDir = path.resolve(rootDir, '.gh-pages')
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
try {
|
|
270
|
+
await runCommand('git', ['worktree', 'remove', worktreeDir, '-f'], {capture: true, cwd: rootDir})
|
|
271
|
+
} catch (_error) {
|
|
272
|
+
// Ignore if worktree doesn't exist
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await runCommand('git', ['worktree', 'add', worktreeDir, 'gh-pages'], {capture: true, cwd: rootDir})
|
|
277
|
+
} catch {
|
|
278
|
+
await runCommand('git', ['worktree', 'add', worktreeDir, '-b', 'gh-pages'], {capture: true, cwd: rootDir})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await runCommand('git', ['-C', worktreeDir, 'config', 'user.name', 'wyxos'], {capture: true})
|
|
282
|
+
await runCommand('git', ['-C', worktreeDir, 'config', 'user.email', 'github@wyxos.com'], {capture: true})
|
|
283
|
+
|
|
284
|
+
for (const entry of fs.readdirSync(worktreeDir)) {
|
|
285
|
+
if (entry === '.git') continue
|
|
286
|
+
const target = path.join(worktreeDir, entry)
|
|
287
|
+
fs.rmSync(target, {recursive: true, force: true})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fs.cpSync(distPath, worktreeDir, {recursive: true})
|
|
291
|
+
|
|
292
|
+
await runCommand('git', ['-C', worktreeDir, 'add', '-A'], {capture: true})
|
|
293
|
+
await runCommand('git', ['-C', worktreeDir, 'commit', '-m', `deploy: demo ${new Date().toISOString()}`, '--allow-empty'], {capture: true})
|
|
294
|
+
await runCommand('git', ['-C', worktreeDir, 'push', '-f', 'origin', 'gh-pages'], {capture: true})
|
|
295
|
+
|
|
296
|
+
logSuccess?.('GitHub Pages deployment completed.')
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error.stdout) {
|
|
299
|
+
writeStderr(error.stdout)
|
|
300
|
+
}
|
|
301
|
+
if (error.stderr) {
|
|
302
|
+
writeStderr(error.stderr)
|
|
303
|
+
}
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function releaseNodePackage({
|
|
309
|
+
releaseType,
|
|
310
|
+
skipTests = false,
|
|
311
|
+
skipLint = false,
|
|
312
|
+
skipBuild = false,
|
|
313
|
+
skipDeploy = false,
|
|
314
|
+
rootDir = process.cwd(),
|
|
315
|
+
logStep,
|
|
316
|
+
logSuccess,
|
|
317
|
+
logWarning
|
|
318
|
+
} = {}) {
|
|
319
|
+
logStep?.('Reading package metadata...')
|
|
320
|
+
const pkg = await readPackage(rootDir)
|
|
321
|
+
|
|
322
|
+
logStep?.('Validating dependencies...')
|
|
323
|
+
await validateReleaseDependencies(rootDir, {logSuccess})
|
|
324
|
+
|
|
325
|
+
logStep?.('Checking working tree status...')
|
|
326
|
+
await ensureCleanWorkingTree(rootDir, {runCommand})
|
|
327
|
+
await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
|
|
328
|
+
|
|
329
|
+
await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning})
|
|
330
|
+
await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning})
|
|
331
|
+
await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning})
|
|
332
|
+
|
|
333
|
+
const updatedPkg = await bumpVersion(releaseType, rootDir, {logStep, logSuccess})
|
|
334
|
+
await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning})
|
|
335
|
+
await pushChanges(rootDir, {logStep, logSuccess})
|
|
336
|
+
await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning})
|
|
337
|
+
|
|
338
|
+
logStep?.('Publishing will be handled by GitHub Actions via trusted publishing.')
|
|
339
|
+
logSuccess?.(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
|
|
340
|
+
}
|