@wyxos/zephyr 0.2.2 → 0.2.4
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 +49 -48
- package/src/index.mjs +18 -1
- package/src/release-node.mjs +462 -462
- package/src/release-packagist.mjs +336 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
import semver from 'semver'
|
|
9
|
+
|
|
10
|
+
const STEP_PREFIX = '→'
|
|
11
|
+
const OK_PREFIX = '✔'
|
|
12
|
+
const WARN_PREFIX = '⚠'
|
|
13
|
+
|
|
14
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
15
|
+
|
|
16
|
+
function logStep(message) {
|
|
17
|
+
console.log(`${STEP_PREFIX} ${message}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function logSuccess(message) {
|
|
21
|
+
console.log(`${OK_PREFIX} ${message}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function logWarning(message) {
|
|
25
|
+
console.warn(`${WARN_PREFIX} ${message}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runCommand(command, args, { cwd = process.cwd(), capture = false, useShell = false } = {}) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const spawnOptions = {
|
|
31
|
+
cwd,
|
|
32
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (useShell || (IS_WINDOWS && (command === 'php' || command === 'composer'))) {
|
|
36
|
+
spawnOptions.shell = true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const child = spawn(command, args, spawnOptions)
|
|
40
|
+
let stdout = ''
|
|
41
|
+
let stderr = ''
|
|
42
|
+
|
|
43
|
+
if (capture) {
|
|
44
|
+
child.stdout.on('data', (chunk) => {
|
|
45
|
+
stdout += chunk
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
child.stderr.on('data', (chunk) => {
|
|
49
|
+
stderr += chunk
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
child.on('error', reject)
|
|
54
|
+
child.on('close', (code) => {
|
|
55
|
+
if (code === 0) {
|
|
56
|
+
resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
|
|
57
|
+
} else {
|
|
58
|
+
const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
|
|
59
|
+
if (capture) {
|
|
60
|
+
error.stdout = stdout
|
|
61
|
+
error.stderr = stderr
|
|
62
|
+
}
|
|
63
|
+
error.exitCode = code
|
|
64
|
+
reject(error)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readComposer(rootDir = process.cwd()) {
|
|
71
|
+
const composerPath = join(rootDir, 'composer.json')
|
|
72
|
+
const raw = await readFile(composerPath, 'utf8')
|
|
73
|
+
return JSON.parse(raw)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function writeComposer(rootDir, composer, composerPath = null) {
|
|
77
|
+
const pathToUse = composerPath || join(rootDir, 'composer.json')
|
|
78
|
+
const content = JSON.stringify(composer, null, 2) + '\n'
|
|
79
|
+
await writeFile(pathToUse, content, 'utf8')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasComposerScript(composer, scriptName) {
|
|
83
|
+
return composer?.scripts?.[scriptName] !== undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function hasLaravelPint(rootDir = process.cwd()) {
|
|
87
|
+
const pintPath = join(rootDir, 'vendor', 'bin', 'pint')
|
|
88
|
+
try {
|
|
89
|
+
await fs.promises.access(pintPath)
|
|
90
|
+
const stats = await fs.promises.stat(pintPath)
|
|
91
|
+
return stats.isFile()
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function hasArtisan(rootDir = process.cwd()) {
|
|
98
|
+
const artisanPath = join(rootDir, 'artisan')
|
|
99
|
+
try {
|
|
100
|
+
await fs.promises.access(artisanPath)
|
|
101
|
+
const stats = await fs.promises.stat(artisanPath)
|
|
102
|
+
return stats.isFile()
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function ensureCleanWorkingTree(rootDir = process.cwd()) {
|
|
109
|
+
const { stdout } = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
|
|
110
|
+
|
|
111
|
+
if (stdout.length > 0) {
|
|
112
|
+
throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function getCurrentBranch(rootDir = process.cwd()) {
|
|
117
|
+
const { stdout } = await runCommand('git', ['branch', '--show-current'], { capture: true, cwd: rootDir })
|
|
118
|
+
return stdout || null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getUpstreamRef(rootDir = process.cwd()) {
|
|
122
|
+
try {
|
|
123
|
+
const { stdout } = await runCommand('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
|
|
124
|
+
capture: true,
|
|
125
|
+
cwd: rootDir
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return stdout || null
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function ensureUpToDateWithUpstream(branch, upstreamRef, rootDir = process.cwd()) {
|
|
135
|
+
if (!upstreamRef) {
|
|
136
|
+
logWarning(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const [remoteName, ...branchParts] = upstreamRef.split('/')
|
|
141
|
+
const remoteBranch = branchParts.join('/')
|
|
142
|
+
|
|
143
|
+
if (remoteName && remoteBranch) {
|
|
144
|
+
logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
|
|
145
|
+
try {
|
|
146
|
+
await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir })
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
|
|
153
|
+
capture: true,
|
|
154
|
+
cwd: rootDir
|
|
155
|
+
})
|
|
156
|
+
const behindResult = await runCommand('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
|
|
157
|
+
capture: true,
|
|
158
|
+
cwd: rootDir
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
|
|
162
|
+
const behind = Number.parseInt(behindResult.stdout || '0', 10)
|
|
163
|
+
|
|
164
|
+
if (Number.isFinite(behind) && behind > 0) {
|
|
165
|
+
if (remoteName && remoteBranch) {
|
|
166
|
+
logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir })
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (Number.isFinite(ahead) && ahead > 0) {
|
|
185
|
+
logWarning(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseArgs() {
|
|
190
|
+
const args = process.argv.slice(2)
|
|
191
|
+
// Filter out --type flag as it's handled by zephyr CLI
|
|
192
|
+
const filteredArgs = args.filter((arg) => !arg.startsWith('--type='))
|
|
193
|
+
const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
|
|
194
|
+
const flags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
|
|
195
|
+
|
|
196
|
+
const releaseType = positionals[0] ?? 'patch'
|
|
197
|
+
const skipTests = flags.has('--skip-tests')
|
|
198
|
+
const skipLint = flags.has('--skip-lint')
|
|
199
|
+
|
|
200
|
+
const allowedTypes = new Set([
|
|
201
|
+
'major',
|
|
202
|
+
'minor',
|
|
203
|
+
'patch',
|
|
204
|
+
'premajor',
|
|
205
|
+
'preminor',
|
|
206
|
+
'prepatch',
|
|
207
|
+
'prerelease'
|
|
208
|
+
])
|
|
209
|
+
|
|
210
|
+
if (!allowedTypes.has(releaseType)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Invalid release type "${releaseType}". Use one of: ${Array.from(allowedTypes).join(', ')}.`
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { releaseType, skipTests, skipLint }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function runLint(skipLint, rootDir = process.cwd()) {
|
|
220
|
+
if (skipLint) {
|
|
221
|
+
logWarning('Skipping lint because --skip-lint flag was provided.')
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const hasPint = await hasLaravelPint(rootDir)
|
|
226
|
+
if (!hasPint) {
|
|
227
|
+
logStep('Skipping lint (Laravel Pint not found).')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
logStep('Running Laravel Pint...')
|
|
232
|
+
const pintPath = IS_WINDOWS ? 'vendor\\bin\\pint' : 'vendor/bin/pint'
|
|
233
|
+
await runCommand('php', [pintPath], { cwd: rootDir })
|
|
234
|
+
logSuccess('Lint passed.')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function runTests(skipTests, composer, rootDir = process.cwd()) {
|
|
238
|
+
if (skipTests) {
|
|
239
|
+
logWarning('Skipping tests because --skip-tests flag was provided.')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const hasArtisanFile = await hasArtisan(rootDir)
|
|
244
|
+
const hasTestScript = hasComposerScript(composer, 'test')
|
|
245
|
+
|
|
246
|
+
if (!hasArtisanFile && !hasTestScript) {
|
|
247
|
+
logStep('Skipping tests (no artisan file or test script found).')
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logStep('Running test suite...')
|
|
252
|
+
|
|
253
|
+
if (hasArtisanFile) {
|
|
254
|
+
await runCommand('php', ['artisan', 'test'], { cwd: rootDir })
|
|
255
|
+
} else if (hasTestScript) {
|
|
256
|
+
await runCommand('composer', ['test'], { cwd: rootDir })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
logSuccess('Tests passed.')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function bumpVersion(releaseType, rootDir = process.cwd()) {
|
|
263
|
+
logStep(`Bumping composer version...`)
|
|
264
|
+
|
|
265
|
+
const composer = await readComposer(rootDir)
|
|
266
|
+
const currentVersion = composer.version || '0.0.0'
|
|
267
|
+
|
|
268
|
+
if (!semver.valid(currentVersion)) {
|
|
269
|
+
throw new Error(`Invalid current version "${currentVersion}" in composer.json. Must be a valid semver.`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const newVersion = semver.inc(currentVersion, releaseType)
|
|
273
|
+
if (!newVersion) {
|
|
274
|
+
throw new Error(`Failed to calculate next ${releaseType} version from ${currentVersion}`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
composer.version = newVersion
|
|
278
|
+
await writeComposer(rootDir, composer)
|
|
279
|
+
|
|
280
|
+
logStep('Staging composer.json...')
|
|
281
|
+
await runCommand('git', ['add', 'composer.json'], { cwd: rootDir })
|
|
282
|
+
|
|
283
|
+
const commitMessage = `chore: release ${newVersion}`
|
|
284
|
+
logStep('Committing version bump...')
|
|
285
|
+
await runCommand('git', ['commit', '-m', commitMessage], { cwd: rootDir })
|
|
286
|
+
|
|
287
|
+
logStep('Creating git tag...')
|
|
288
|
+
await runCommand('git', ['tag', `v${newVersion}`], { cwd: rootDir })
|
|
289
|
+
|
|
290
|
+
logSuccess(`Version updated to ${newVersion}.`)
|
|
291
|
+
return { ...composer, version: newVersion }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function pushChanges(rootDir = process.cwd()) {
|
|
295
|
+
logStep('Pushing commits to origin...')
|
|
296
|
+
await runCommand('git', ['push'], { cwd: rootDir })
|
|
297
|
+
|
|
298
|
+
logStep('Pushing tags to origin...')
|
|
299
|
+
await runCommand('git', ['push', 'origin', '--tags'], { cwd: rootDir })
|
|
300
|
+
|
|
301
|
+
logSuccess('Git push completed.')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function releasePackagist() {
|
|
305
|
+
const { releaseType, skipTests, skipLint } = parseArgs()
|
|
306
|
+
const rootDir = process.cwd()
|
|
307
|
+
|
|
308
|
+
logStep('Reading composer metadata...')
|
|
309
|
+
const composer = await readComposer(rootDir)
|
|
310
|
+
|
|
311
|
+
if (!composer.version) {
|
|
312
|
+
throw new Error('composer.json does not have a version field. Add "version": "0.0.0" to composer.json.')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
logStep('Checking working tree status...')
|
|
316
|
+
await ensureCleanWorkingTree(rootDir)
|
|
317
|
+
|
|
318
|
+
const branch = await getCurrentBranch(rootDir)
|
|
319
|
+
if (!branch) {
|
|
320
|
+
throw new Error('Unable to determine current branch.')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
logStep(`Current branch: ${branch}`)
|
|
324
|
+
const upstreamRef = await getUpstreamRef(rootDir)
|
|
325
|
+
await ensureUpToDateWithUpstream(branch, upstreamRef, rootDir)
|
|
326
|
+
|
|
327
|
+
await runLint(skipLint, rootDir)
|
|
328
|
+
await runTests(skipTests, composer, rootDir)
|
|
329
|
+
|
|
330
|
+
const updatedComposer = await bumpVersion(releaseType, rootDir)
|
|
331
|
+
await pushChanges(rootDir)
|
|
332
|
+
|
|
333
|
+
logSuccess(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
|
|
334
|
+
logStep('Note: Packagist will automatically detect the new git tag and update the package.')
|
|
335
|
+
}
|
|
336
|
+
|