trackfw 7.0.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.
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/commit.js +7 -3
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +33 -0
- package/src/commands/index.js +3 -0
- package/src/commands/integrations.js +79 -2
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +10 -2
- package/src/commands/update-harness.js +319 -12
- package/src/commands/update.js +1 -1
- package/src/config/index.js +17 -2
- package/src/generators/hooks.js +650 -45
- package/src/generators/init.js +12 -0
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/assets/agents/architect.md +12 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/manager.js +118 -17
- package/src/integrations/render.js +116 -2
- package/src/lib/fatal-error.js +64 -0
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +226 -93
- package/src/validator/index.js +686 -95
|
@@ -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
|
+
}
|