trackfw 7.1.0 → 7.2.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.
@@ -0,0 +1,474 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * release/runner.js — Core implementation of `trackfw release tag`.
5
+ *
6
+ * Port of internal/commands/release.go — keep in sync. See
7
+ * ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md for why this exists
8
+ * as a separate command from `ship`: tag is not a branch operation, and ship's governance gate
9
+ * ("REQ + roadmap in wip/") does not apply to release.
10
+ *
11
+ * All git/gh operations are injectable for testability. Publishes via two `gh api` calls
12
+ * (POST git/tags then POST git/refs) — the reference sequence validated in production for
13
+ * v7.1.0 — which preserves the tag's annotation; a plain `git push origin <tag>` from a
14
+ * lightweight local tag would lose it, and the git-branch-guard blocks that push form anyway.
15
+ */
16
+
17
+ const { spawnSync } = require('child_process')
18
+ const { load: loadConfig } = require('../config')
19
+ const { resolve: forgeResolve } = require('../forge/resolve')
20
+ const { forgeAdapter } = require('../forge/adapter')
21
+ const changelog = require('../changelog')
22
+
23
+ // Named refusal message builders — kept byte-identical (by construction) to Go's
24
+ // releaseTag*Fmt constants (internal/commands/release.go) and Python's RELEASE_TAG_* strings,
25
+ // so the ML-2B parity gate can compare all 3 CLIs. Every precondition refusal names what to
26
+ // fix — release tag prefers refusing over guessing.
27
+ function dirtyTreeMsg(statusOut) {
28
+ return `trackfw release tag refuses to run: working tree is not clean.\n${statusOut}\nCommit your changes (trackfw commit) before tagging a release.`
29
+ }
30
+
31
+ function fetchFailedMsg(errMessage) {
32
+ return `trackfw release tag refuses to run: could not fetch origin (${errMessage}). Check your network/credentials and retry.`
33
+ }
34
+
35
+ function localBranchStaleMsg(base, localSHA, remoteSHA) {
36
+ return `trackfw release tag refuses to run: local "${base}" is not up to date with origin/${base} (local ${localSHA}, remote ${remoteSHA}). Run: git pull`
37
+ }
38
+
39
+ function versionMismatchMsg(label, got, want) {
40
+ return `trackfw release tag refuses to run: ${label} has version "${got}", expected "${want}". Update it to match before tagging.`
41
+ }
42
+
43
+ function changelogMissingMsg(underlyingMessage, version) {
44
+ return `trackfw release tag refuses to run: ${underlyingMessage}. Add a "## [${version}] - YYYY-MM-DD" section to CHANGELOG.md before tagging.`
45
+ }
46
+
47
+ function existsLocalMsg(tagName) {
48
+ return `trackfw release tag refuses to run: tag "${tagName}" already exists locally. Delete it first (git tag -d ${tagName}) or choose a different version.`
49
+ }
50
+
51
+ function existsRemoteMsg(tagName) {
52
+ return `trackfw release tag refuses to run: tag "${tagName}" already exists on origin. Choose a different version.`
53
+ }
54
+
55
+ function noForgeCLIMsg(tagName, objectSHA) {
56
+ return `trackfw release tag requires the GitHub CLI (gh) to publish the tag. No forge CLI is available for this repository — install and authenticate gh, or push the tag manually: git tag -a ${tagName} -m "<CHANGELOG.md section>" ${objectSHA} && git push origin ${tagName}`
57
+ }
58
+
59
+ function unsupportedForgeMsg(resolvedForge, tagName, objectSHA) {
60
+ return `trackfw release tag currently only supports GitHub (resolved forge: "${resolvedForge}"). Publishing tag ${tagName} on this forge is not implemented yet — commit to tag: ${objectSHA}. Create ${tagName} through your forge's web UI, or open an issue requesting support for this forge.`
61
+ }
62
+
63
+ const NO_GIT_IDENTITY_MSG =
64
+ 'trackfw release tag refuses to run: git config user.name and user.email must be set to create an annotated tag (git config user.name "Your Name" && git config user.email you@example.com).'
65
+
66
+ // commitDivergesMsg fires when a LOCAL ref (origin/<forge's default branch>'s resolved sha)
67
+ // disagrees with what the forge itself reports for that same branch. This ref is writable inside
68
+ // the clone (git update-ref) — the forge is the only source that is not — so a disagreement is
69
+ // refused, never silently resolved by picking one side. The BRANCH NAME itself comes from the
70
+ // forge unconditionally (no local-vs-forge name check — see the call site) since a fresh/shallow
71
+ // clone legitimately has no local opinion on it at all. See ADR-2026-08-19-caminho-governado-
72
+ // para-push-forcado-e-tag-de-release.md, Emenda 1.
73
+ function commitDivergesMsg(base, localSHA, forgeBase, forgeSHA) {
74
+ return `trackfw release tag refuses to run: local origin/${base} (${localSHA}) diverges from the forge's ${forgeBase} tip (${forgeSHA}). A local ref can be stale or forged — investigate before retrying: git fetch origin --prune`
75
+ }
76
+
77
+ // objectAbsentMsg fires when git show <sha>:<path> fails (object absent locally after the
78
+ // fetch that Precondition 2 already ran). Names the path and the sha so the user knows
79
+ // exactly what is missing. Never falls back to the working tree. See ADR-2026-08-21-
80
+ // release-tag-le-versao-e-changelog-do-commit-ancorado.md.
81
+ function objectAbsentMsg(filePath, sha, errMessage) {
82
+ return `trackfw release tag refuses to run: could not read ${filePath} at commit ${sha}: ${errMessage}`
83
+ }
84
+
85
+ // ─── Version file extraction ───────────────────────────────────────────────
86
+
87
+ const GO_VERSION_RE = /Version\s*=\s*"([^"]+)"/
88
+ const PYPROJECT_VERSION_RE = /^version\s*=\s*"([^"]+)"/m
89
+ // Matches the try-block fallback in `__version__ = version("trackfw") or "7.1.0"`.
90
+ const INIT_TRY_VERSION_RE = /or\s+"([^"]+)"/
91
+ // Matches the except-block's `__version__ = "7.1.0"` — distinct from the try-block line, which
92
+ // never starts with `__version__ = "` directly (it starts with `__version__ = version(...)`).
93
+ const INIT_EXCEPT_VERSION_RE = /__version__\s*=\s*"([^"]+)"/
94
+
95
+ function extractGoVersion(content) {
96
+ const m = GO_VERSION_RE.exec(content)
97
+ if (!m) throw new Error('could not find Version = "..." in internal/version/version.go')
98
+ return m[1]
99
+ }
100
+
101
+ function extractNpmVersion(content) {
102
+ let pkg
103
+ try {
104
+ pkg = JSON.parse(content)
105
+ } catch (e) {
106
+ throw new Error(`could not parse npm/package.json: ${e.message}`)
107
+ }
108
+ if (!pkg.version) throw new Error('npm/package.json has no "version" field')
109
+ return pkg.version
110
+ }
111
+
112
+ function extractPyprojectVersion(content) {
113
+ const m = PYPROJECT_VERSION_RE.exec(content)
114
+ if (!m) throw new Error('could not find version = "..." in pypi/pyproject.toml')
115
+ return m[1]
116
+ }
117
+
118
+ function extractInitTryVersion(content) {
119
+ const m = INIT_TRY_VERSION_RE.exec(content)
120
+ if (!m) throw new Error('could not find the importlib.metadata fallback version in pypi/trackfw/__init__.py')
121
+ return m[1]
122
+ }
123
+
124
+ function extractInitExceptVersion(content) {
125
+ const m = INIT_EXCEPT_VERSION_RE.exec(content)
126
+ if (!m) throw new Error('could not find the except fallback version in pypi/trackfw/__init__.py')
127
+ return m[1]
128
+ }
129
+
130
+ const RELEASE_VERSION_FILES = [
131
+ { label: 'internal/version/version.go', path: 'internal/version/version.go', extract: extractGoVersion },
132
+ { label: 'npm/package.json', path: 'npm/package.json', extract: extractNpmVersion },
133
+ { label: 'pypi/pyproject.toml', path: 'pypi/pyproject.toml', extract: extractPyprojectVersion },
134
+ { label: 'pypi/trackfw/__init__.py (importlib.metadata fallback)', path: 'pypi/trackfw/__init__.py', extract: extractInitTryVersion },
135
+ { label: 'pypi/trackfw/__init__.py (except fallback)', path: 'pypi/trackfw/__init__.py', extract: extractInitExceptVersion },
136
+ ]
137
+
138
+ /** normalizeReleaseVersion strips an optional leading "v"/"V". */
139
+ function normalizeReleaseVersion(v) {
140
+ if (v && (v[0] === 'v' || v[0] === 'V')) return v.slice(1)
141
+ return v
142
+ }
143
+
144
+ // ─── Default dependency implementations ────────────────────────────────────
145
+
146
+ function defaultExecGit(args) {
147
+ const result = spawnSync('git', args, { encoding: 'utf8' })
148
+ if (result.status !== 0) {
149
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
150
+ return { stdout: '', error: new Error(msg) }
151
+ }
152
+ return { stdout: (result.stdout || '').trim(), error: null }
153
+ }
154
+
155
+ // defaultReadAtCommit reads a file from a specific commit object (git show <sha>:<path>)
156
+ // and returns the content verbatim — stdout is NOT trimmed because callers rely on
157
+ // byte-exact content (CHANGELOG sections, version strings with newlines). On any failure
158
+ // the error surfaces git's real stderr; there is NO fallback to the working tree.
159
+ // See ADR-2026-08-21-release-tag-le-versao-e-changelog-do-commit-ancorado.md.
160
+ function defaultReadAtCommit(sha, filePath) {
161
+ const result = spawnSync('git', ['--no-replace-objects', 'show', `${sha}:${filePath}`], { encoding: 'utf8' })
162
+ if (result.status !== 0) {
163
+ const msg = (result.stderr || '').trim() || `git show ${sha}:${filePath} exited with ${result.status}`
164
+ return { content: '', error: new Error(msg) }
165
+ }
166
+ // NOT trimmed — content must be preserved byte-for-byte.
167
+ return { content: result.stdout, error: null }
168
+ }
169
+
170
+ /**
171
+ * defaultExecForgeAPI runs a forge CLI command feeding stdin and capturing stdout, so the JSON
172
+ * response can be parsed. On failure, surfaces the CLI's real stderr text.
173
+ * @returns {{ stdout: string, error: Error|null }}
174
+ */
175
+ function defaultExecForgeAPI(name, args, stdin) {
176
+ const result = spawnSync(name, args, { input: stdin, encoding: 'utf8' })
177
+ if (result.status !== 0) {
178
+ const msg = (result.stderr || '').trim() || `${name} ${args.join(' ')} exited with ${result.status}`
179
+ return { stdout: (result.stdout || '').trim(), error: new Error(msg) }
180
+ }
181
+ return { stdout: (result.stdout || '').trim(), error: null }
182
+ }
183
+
184
+ /**
185
+ * runReleaseTag implements `trackfw release tag <version>`. Every precondition below is
186
+ * checked before any write — the risk this command carries is publishing a wrong tag to a
187
+ * public repository, so it always refuses rather than guesses.
188
+ * @param {string} versionArg
189
+ * @param {object} deps
190
+ * @returns {number} exit code (0 = success)
191
+ */
192
+ function runReleaseTag(versionArg, deps = {}) {
193
+ const execGit = deps.execGit || defaultExecGit
194
+ // readAtCommit reads a file from a specific commit object. Default reads via git show so
195
+ // the authority is the sha (content-addressed), not the working tree. Returns
196
+ // { content: string, error: Error|null } — content is NOT trimmed (byte-exact for parsers).
197
+ const readAtCommit = deps.readAtCommit || defaultReadAtCommit
198
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
199
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
200
+ const configForge = deps.configForge || ''
201
+ const repoDir = deps.repoDir !== undefined ? deps.repoDir : '.'
202
+ const availFn = deps.availFn || undefined
203
+ const execForgeAPI = deps.execForgeAPI || defaultExecForgeAPI
204
+
205
+ const version = normalizeReleaseVersion(String(versionArg).trim())
206
+ const tagName = `v${version}`
207
+
208
+ // ─── Precondition 1: clean working tree ──────────────────────────────────
209
+ const statusResult = execGit(['status', '--porcelain'])
210
+ if (statusResult.error) {
211
+ writeErr(`could not determine working tree status: ${statusResult.error.message}`)
212
+ return 1
213
+ }
214
+ if (statusResult.stdout.trim() !== '') {
215
+ writeErr(dirtyTreeMsg(statusResult.stdout))
216
+ return 1
217
+ }
218
+
219
+ // ─── Precondition 2: default branch up to date with origin ──────────────
220
+ // base is symref-derived — a LOCAL, gravable ref. Used below only as (a) the value the
221
+ // forge's default_branch must agree with, and (b) input to the local-branch-staleness check,
222
+ // unrelated to the forge and unaffected by it.
223
+ const fetchResult = execGit(['fetch', 'origin', '--prune'])
224
+ if (fetchResult.error) {
225
+ writeErr(fetchFailedMsg(fetchResult.error.message))
226
+ return 1
227
+ }
228
+
229
+ const base = defaultBaseBranch(execGit)
230
+
231
+ // localSHA (origin/<base>'s local tracking ref) is best-effort and non-fatal: a cross-check
232
+ // candidate against the forge, never the source of the commit target. A failure to resolve it
233
+ // must not block reaching the forge resolution below.
234
+ let localSHA = ''
235
+ const objResult = execGit(['rev-parse', `origin/${base}`])
236
+ if (!objResult.error) {
237
+ localSHA = objResult.stdout.trim()
238
+
239
+ const localBranchExists = execGit(['rev-parse', '-q', '--verify', `refs/heads/${base}`])
240
+ if (!localBranchExists.error) {
241
+ const localResult = execGit(['rev-parse', `refs/heads/${base}`])
242
+ if (!localResult.error) {
243
+ const localBranchSHA = localResult.stdout.trim()
244
+ if (localBranchSHA !== localSHA) {
245
+ writeErr(localBranchStaleMsg(base, localBranchSHA, localSHA))
246
+ return 1
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // ─── Precondition 5: tag must not already exist, local or remote ────────
253
+ const localTagExists = execGit(['rev-parse', '-q', '--verify', `refs/tags/${tagName}`])
254
+ if (!localTagExists.error) {
255
+ writeErr(existsLocalMsg(tagName))
256
+ return 1
257
+ }
258
+ const remoteTagResult = execGit(['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`])
259
+ if (remoteTagResult.stdout.trim() !== '') {
260
+ writeErr(existsRemoteMsg(tagName))
261
+ return 1
262
+ }
263
+
264
+ // ─── Precondition 6: forge CLI available — GitHub only, for now ─────────
265
+ const remoteURLResult = execGit(['remote', 'get-url', 'origin'])
266
+ const remoteURL = (remoteURLResult.stdout || '').trim()
267
+
268
+ let resolution
269
+ try {
270
+ resolution = forgeResolve({ configForge, remoteURL, repoDir })
271
+ } catch (e) {
272
+ writeErr(e.message)
273
+ return 1
274
+ }
275
+
276
+ if (resolution.forge !== 'github') {
277
+ // No forge to ask — localSHA is shown purely as an informational hint for the manual
278
+ // fallback text below; the command never publishes on this path.
279
+ writeErr(unsupportedForgeMsg(resolution.forge, tagName, localSHA))
280
+ return 1
281
+ }
282
+
283
+ const adapter = forgeAdapter(resolution.forge, availFn)
284
+ if (!adapter.available) {
285
+ // Same reasoning as above: no forge CLI to ask, localSHA is informational only.
286
+ writeErr(noForgeCLIMsg(tagName, localSHA))
287
+ return 1
288
+ }
289
+
290
+ // ─── The commit-target comes from the forge, never from a local ref ─────
291
+ // The forge's default_branch is authoritative for the BRANCH NAME — unconditionally, with no
292
+ // refusal if it disagrees with the local symref-derived base (a fresh/shallow clone may have
293
+ // no origin/HEAD symref at all, defaultBaseBranch then falls back to "main"; refusing on that
294
+ // mismatch would be a false refusal against a legitimate repo, not a security check). Only the
295
+ // forge's SHA is cross-checked against a local ref — resolved fresh, keyed to the forge's own
296
+ // branch name, never to the (possibly-forged) local base. See ADR-2026-08-19-caminho-
297
+ // governado-para-push-forcado-e-tag-de-release.md, Emenda 1.
298
+ const repoInfoResp = execForgeAPI('gh', ['api', 'repos/{owner}/{repo}'], '')
299
+ if (repoInfoResp.error) {
300
+ writeErr(`trackfw release tag: gh api failed resolving the repository's default branch from the forge: ${repoInfoResp.error.message}`)
301
+ return 1
302
+ }
303
+ let repoInfo
304
+ try {
305
+ repoInfo = JSON.parse(repoInfoResp.stdout)
306
+ } catch (_) {
307
+ repoInfo = {}
308
+ }
309
+ if (!repoInfo.default_branch) {
310
+ writeErr(`trackfw release tag: could not parse default_branch from the forge's repository response: ${repoInfoResp.stdout}`)
311
+ return 1
312
+ }
313
+
314
+ // forgeLocalSHA is resolved fresh against the forge's own branch name — deliberately NOT
315
+ // reusing localSHA above, which was keyed to the symref-derived base and may name a different
316
+ // branch (stale symref, or a fresh clone with no symref at all). Best-effort/non-fatal.
317
+ let forgeLocalSHA = ''
318
+ const forgeObjResult = execGit(['rev-parse', `origin/${repoInfo.default_branch}`])
319
+ if (!forgeObjResult.error) {
320
+ forgeLocalSHA = forgeObjResult.stdout.trim()
321
+ }
322
+
323
+ const commitResp = execForgeAPI('gh', ['api', `repos/{owner}/{repo}/commits/${repoInfo.default_branch}`], '')
324
+ if (commitResp.error) {
325
+ writeErr(`trackfw release tag: gh api failed resolving the forge's tip commit for ${repoInfo.default_branch}: ${commitResp.error.message}`)
326
+ return 1
327
+ }
328
+ let commitObj
329
+ try {
330
+ commitObj = JSON.parse(commitResp.stdout)
331
+ } catch (_) {
332
+ commitObj = {}
333
+ }
334
+ if (!commitObj.sha) {
335
+ writeErr(`trackfw release tag: could not parse the forge's commit response for ${repoInfo.default_branch}: ${commitResp.stdout}`)
336
+ return 1
337
+ }
338
+
339
+ if (forgeLocalSHA && forgeLocalSHA !== commitObj.sha) {
340
+ writeErr(commitDivergesMsg(repoInfo.default_branch, forgeLocalSHA, repoInfo.default_branch, commitObj.sha))
341
+ return 1
342
+ }
343
+
344
+ // objectSHA is now authoritative — resolved from the forge, cross-checked (not sourced)
345
+ // against the local ref above.
346
+ const objectSHA = commitObj.sha
347
+
348
+ // ─── Precondition 3: version files in the commit-target must all match ──
349
+ // Content is read from objectSHA via git show, NOT from the working tree. Objects are
350
+ // content-addressed: given a sha from the forge, the content is cyptographically determined —
351
+ // a local edit that was not committed cannot influence the tag message.
352
+ // Absent object → refuse naming sha+path; never fall back to local. See ADR-2026-08-21.
353
+ for (const vf of RELEASE_VERSION_FILES) {
354
+ const readResult = readAtCommit(objectSHA, vf.path)
355
+ if (readResult.error) {
356
+ writeErr(objectAbsentMsg(vf.path, objectSHA, readResult.error.message))
357
+ return 1
358
+ }
359
+ let got
360
+ try {
361
+ got = vf.extract(readResult.content)
362
+ } catch (e) {
363
+ writeErr(`trackfw release tag refuses to run: ${e.message}`)
364
+ return 1
365
+ }
366
+ if (got !== version) {
367
+ writeErr(versionMismatchMsg(vf.label, got, version))
368
+ return 1
369
+ }
370
+ }
371
+
372
+ // ─── Precondition 4: CHANGELOG.md in the commit-target has the version's section ──
373
+ // Same anchoring as P3: content comes from objectSHA, never from the working tree.
374
+ const changelogReadResult = readAtCommit(objectSHA, 'CHANGELOG.md')
375
+ if (changelogReadResult.error) {
376
+ writeErr(objectAbsentMsg('CHANGELOG.md', objectSHA, changelogReadResult.error.message))
377
+ return 1
378
+ }
379
+ const sections = changelog.parseSections(changelogReadResult.content)
380
+ let section
381
+ try {
382
+ section = changelog.findVersion(sections, version)
383
+ } catch (e) {
384
+ writeErr(changelogMissingMsg(e.message, version))
385
+ return 1
386
+ }
387
+ const tagMessage = changelog.formatSection(section)
388
+
389
+ // ─── Tagger identity ──────────────────────────────────────────────────
390
+ const nameResult = execGit(['config', 'user.name'])
391
+ const emailResult = execGit(['config', 'user.email'])
392
+ const name = (nameResult.stdout || '').trim()
393
+ const email = (emailResult.stdout || '').trim()
394
+ if (!name || !email) {
395
+ writeErr(NO_GIT_IDENTITY_MSG)
396
+ return 1
397
+ }
398
+
399
+ // ─── Publish: two gh api calls, preserving the annotation ───────────────
400
+ const tagPayload = JSON.stringify({
401
+ tag: tagName,
402
+ message: tagMessage,
403
+ object: objectSHA,
404
+ type: 'commit',
405
+ // RFC3339, no fractional seconds — matches Go's time.Now().UTC().Format(time.RFC3339) and
406
+ // Python's strftime("%Y-%m-%dT%H:%M:%SZ") exactly (Date.prototype.toISOString() emits
407
+ // milliseconds, which those two do not; a byte-for-byte-format gate would catch the drift
408
+ // even though the *value* always differs by wall-clock time anyway).
409
+ tagger: { name, email, date: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') },
410
+ })
411
+
412
+ const tagResp = execForgeAPI('gh', ['api', 'repos/{owner}/{repo}/git/tags', '--method', 'POST', '--input', '-'], tagPayload)
413
+ if (tagResp.error) {
414
+ writeErr(`trackfw release tag: gh api failed creating the tag object: ${tagResp.error.message}`)
415
+ return 1
416
+ }
417
+
418
+ let tagObj
419
+ try {
420
+ tagObj = JSON.parse(tagResp.stdout)
421
+ } catch (_) {
422
+ tagObj = {}
423
+ }
424
+ if (!tagObj.sha) {
425
+ writeErr(`trackfw release tag: could not parse the tag object response from gh api: ${tagResp.stdout}`)
426
+ return 1
427
+ }
428
+
429
+ const refPayload = JSON.stringify({ ref: `refs/tags/${tagName}`, sha: tagObj.sha })
430
+ const refResp = execForgeAPI('gh', ['api', 'repos/{owner}/{repo}/git/refs', '--method', 'POST', '--input', '-'], refPayload)
431
+ if (refResp.error) {
432
+ writeErr(`trackfw release tag: gh api failed creating the tag ref: ${refResp.error.message}`)
433
+ return 1
434
+ }
435
+
436
+ writeln(`Tag published: ${tagName}`)
437
+ writeln(` tag object: ${tagObj.sha}`)
438
+ writeln(` commit: ${objectSHA}`)
439
+ writeln('')
440
+ writeln('release tag complete.')
441
+ return 0
442
+ }
443
+
444
+ // GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX — see ship/runner.js's identical constant/rationale.
445
+ const GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX = 'refs/remotes/origin/'
446
+
447
+ /**
448
+ * defaultBaseBranch resolves the repository's default branch, mirroring Go's
449
+ * defaultBaseBranch (ship.go) exactly: tries symbolic-ref refs/remotes/origin/HEAD, falls back
450
+ * to "main". This is a LOCAL, gravable ref — release tag treats its result as a cross-check
451
+ * candidate only, never as the source of truth for the tag's commit target.
452
+ * @param {function} execGit
453
+ * @returns {string}
454
+ */
455
+ function defaultBaseBranch(execGit) {
456
+ const result = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
457
+ if (result.error) return 'main'
458
+ const out = result.stdout.trim()
459
+ if (!out.startsWith(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX)) return 'main'
460
+ const name = out.slice(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX.length)
461
+ return name === '' ? 'main' : name
462
+ }
463
+
464
+ module.exports = {
465
+ runReleaseTag,
466
+ normalizeReleaseVersion,
467
+ RELEASE_VERSION_FILES,
468
+ extractGoVersion,
469
+ extractNpmVersion,
470
+ extractPyprojectVersion,
471
+ extractInitTryVersion,
472
+ extractInitExceptVersion,
473
+ defaultBaseBranch,
474
+ }