@brainervirus/workit-core 8.12.3 → 8.13.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/package.json +1 -1
- package/skills/workit-ship/SKILL.md +20 -20
- package/skills/workit-ship/references/release.md +26 -0
- package/src/core/branch-policy.ts +15 -0
- package/src/core/methods.ts +12 -11
- package/src/forge/github.ts +30 -0
- package/src/forge/gitlab.ts +33 -0
- package/src/forge/types.ts +10 -0
- package/src/git/ops.ts +161 -46
- package/src/git/release.ts +719 -0
- package/src/hooks/raw-git.ts +7 -5
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// `workit release start|finish|publish`: a git-flow release or hotfix without
|
|
2
|
+
// the git-flow binary, with `git flow release finish` semantics.
|
|
3
|
+
//
|
|
4
|
+
// - start cuts release/<version> (hotfix/<version> from production) on the
|
|
5
|
+
// line's integration branch through gitBranch, locally. It never pushes.
|
|
6
|
+
// - finish (local half, here): --no-ff merge of the release branch into
|
|
7
|
+
// production, an annotated tag <prefix><version> on that merge (message: the
|
|
8
|
+
// changelog section when there is one), a --no-ff merge of the tag into
|
|
9
|
+
// every merge-back branch (git-flow merges back through the tag), then
|
|
10
|
+
// `git branch -d` of the release branch. Every step is decided from the
|
|
11
|
+
// repository state plus the ledger rows of earlier runs, so an interrupted
|
|
12
|
+
// finish resumes without repeating a merge or a tag. The CLI verb then
|
|
13
|
+
// pushes production, the merge-back branches and the tag through the
|
|
14
|
+
// existing `--release` push, publishes the forge release and verifies it.
|
|
15
|
+
// - publish is the one path that pushes a release branch (verbs/release.ts).
|
|
16
|
+
import { readLedger, appendObserved, type LedgerActor } from "../ledger";
|
|
17
|
+
import { failure, success, type ForgeResult } from "../forge/types";
|
|
18
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
19
|
+
import { trackBranchName } from "../core/release-tracks";
|
|
20
|
+
import { resolveRuntimeWorkspacePolicy } from "../core/workspaces";
|
|
21
|
+
import {
|
|
22
|
+
git,
|
|
23
|
+
gitBranch,
|
|
24
|
+
gitError,
|
|
25
|
+
inProgressFailure,
|
|
26
|
+
operationInProgress,
|
|
27
|
+
releaseLinesOf,
|
|
28
|
+
type ReleaseLine,
|
|
29
|
+
} from "./ops";
|
|
30
|
+
import { currentBranch, fetchRefs, pushRemoteName, remoteRefTip, resolveRef } from "./rev";
|
|
31
|
+
|
|
32
|
+
export type ReleaseKind = "release" | "hotfix";
|
|
33
|
+
|
|
34
|
+
const VERSION = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,99}$/u;
|
|
35
|
+
const PLACEHOLDER = /\{[^}]*\}/gu;
|
|
36
|
+
|
|
37
|
+
const validVersion = (version: string): boolean =>
|
|
38
|
+
VERSION.test(version) && !version.includes("..") && !version.endsWith(".lock");
|
|
39
|
+
|
|
40
|
+
/** The branch template of a line and kind: the track's naming, else `<kind>/{version}`. */
|
|
41
|
+
const templateOf = (line: ReleaseLine, kind: ReleaseKind): string => {
|
|
42
|
+
const template = line.naming[kind];
|
|
43
|
+
if (!template) return `${kind}/{version}`;
|
|
44
|
+
return /\{[^}]*\}/u.test(template) ? template : `${template.replace(/\/?\*?$/u, "")}/{version}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const branchFor = (line: ReleaseLine, kind: ReleaseKind, version: string): string =>
|
|
48
|
+
trackBranchName({ naming: line.naming }, kind, version) ?? `${kind}/${version}`;
|
|
49
|
+
|
|
50
|
+
/** The version a release branch name carries under `template`, or null. */
|
|
51
|
+
const versionIn = (template: string, branch: string): string | null => {
|
|
52
|
+
const pattern = template
|
|
53
|
+
.split(PLACEHOLDER)
|
|
54
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
|
|
55
|
+
.join("(.+)");
|
|
56
|
+
const match = new RegExp(`^${pattern}$`, "u").exec(branch);
|
|
57
|
+
return match?.[1] && validVersion(match[1]) ? match[1] : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type ReleaseRow = Record<string, unknown> & { type: string };
|
|
61
|
+
|
|
62
|
+
/** This repository's release.* ledger rows, oldest first. */
|
|
63
|
+
const releaseRows = (cwd: string): ReleaseRow[] => {
|
|
64
|
+
const ledger = readLedger(cwd);
|
|
65
|
+
if (!ledger.ok) return [];
|
|
66
|
+
return ledger.value.rows.filter((row) => row.type.startsWith("release."));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const record = (cwd: string, actor: LedgerActor, row: ReleaseRow): void => {
|
|
70
|
+
appendObserved(cwd, { ...row, actor });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// start
|
|
75
|
+
|
|
76
|
+
export type ReleaseStartOutcome = {
|
|
77
|
+
version: string;
|
|
78
|
+
kind: ReleaseKind;
|
|
79
|
+
branch: string;
|
|
80
|
+
base: string;
|
|
81
|
+
baseSha: string;
|
|
82
|
+
track: string | null;
|
|
83
|
+
tag: string;
|
|
84
|
+
notes: string[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The line `--track`, else the checkout's resolved release track, names. */
|
|
88
|
+
function lineFor(cwd: string, track: string | null): ForgeResult<ReleaseLine> {
|
|
89
|
+
const lines = releaseLinesOf(cwd);
|
|
90
|
+
let name = track;
|
|
91
|
+
if (name === null && lines.length > 1) {
|
|
92
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
93
|
+
if (resolved.ok === false) return failure("blocked", String(resolved.error));
|
|
94
|
+
if (resolved.releaseTrack?.blocking)
|
|
95
|
+
return failure("blocked", String(resolved.releaseTrack.blocking), "pass --track <name>");
|
|
96
|
+
name = resolved.releaseTrack?.track?.name ?? null;
|
|
97
|
+
}
|
|
98
|
+
const line = lines.find((entry) => entry.track === name);
|
|
99
|
+
if (!line)
|
|
100
|
+
return failure(
|
|
101
|
+
"invalid_input",
|
|
102
|
+
`no release track ${JSON.stringify(name)} (configured: ${
|
|
103
|
+
lines
|
|
104
|
+
.map((entry) => entry.track)
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(", ") || "none"
|
|
107
|
+
})`,
|
|
108
|
+
);
|
|
109
|
+
return success(line);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function releaseStart(
|
|
113
|
+
cwd: string,
|
|
114
|
+
input: { version: string; hotfix?: boolean; track?: string | null; actor: LedgerActor },
|
|
115
|
+
): ForgeResult<ReleaseStartOutcome> {
|
|
116
|
+
const version = input.version.trim();
|
|
117
|
+
if (!validVersion(version))
|
|
118
|
+
return failure("invalid_input", `${JSON.stringify(version)} is not a version (e.g. 1.4.0)`);
|
|
119
|
+
const kind: ReleaseKind = input.hotfix ? "hotfix" : "release";
|
|
120
|
+
const lineResult = lineFor(cwd, input.track ?? null);
|
|
121
|
+
if (!lineResult.ok) return lineResult;
|
|
122
|
+
const line = lineResult.data;
|
|
123
|
+
// git-flow allows one release (or hotfix) in flight per line.
|
|
124
|
+
const template = templateOf(line, kind);
|
|
125
|
+
const existing = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
126
|
+
.stdout.split("\n")
|
|
127
|
+
.filter((name) => name && versionIn(template, name) !== null);
|
|
128
|
+
if (existing.length)
|
|
129
|
+
return failure(
|
|
130
|
+
"blocked",
|
|
131
|
+
`release_exists: ${existing[0]} is still open; git-flow finishes one ${kind} before starting the next`,
|
|
132
|
+
`workit release finish # on ${existing[0]}`,
|
|
133
|
+
);
|
|
134
|
+
const tag = `${line.tagPrefix}${version}`;
|
|
135
|
+
if (resolveRef(cwd, `refs/tags/${tag}`))
|
|
136
|
+
return failure(
|
|
137
|
+
"blocked",
|
|
138
|
+
`tag_exists: ${tag} already exists`,
|
|
139
|
+
"start the next version instead",
|
|
140
|
+
);
|
|
141
|
+
const base = kind === "hotfix" ? line.production : line.integration;
|
|
142
|
+
// The release starts from the fetched remote base (gitBranch); local
|
|
143
|
+
// commits on it that the remote lacks would silently stay out of it.
|
|
144
|
+
const remote = pushRemoteName(cwd, base);
|
|
145
|
+
if (remote) {
|
|
146
|
+
fetchRefs(cwd, remote, [`+refs/heads/${base}:refs/remotes/${remote}/${base}`]);
|
|
147
|
+
const local = resolveRef(cwd, `refs/heads/${base}`);
|
|
148
|
+
const tracked = resolveRef(cwd, `refs/remotes/${remote}/${base}`);
|
|
149
|
+
if (
|
|
150
|
+
local &&
|
|
151
|
+
tracked &&
|
|
152
|
+
local !== tracked &&
|
|
153
|
+
!git(cwd, ["merge-base", "--is-ancestor", local, tracked]).ok
|
|
154
|
+
)
|
|
155
|
+
return failure(
|
|
156
|
+
"blocked",
|
|
157
|
+
`base_unpushed: local ${base} has commits ${remote}/${base} lacks; the ${kind} would start without them`,
|
|
158
|
+
`land them on ${remote}/${base} first, or: git switch ${base} && git reset --keep ${remote}/${base}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const branched = gitBranch(cwd, {
|
|
162
|
+
name: branchFor(line, kind, version),
|
|
163
|
+
base,
|
|
164
|
+
...(line.track ? { track: line.track } : {}),
|
|
165
|
+
});
|
|
166
|
+
if (!branched.ok) return branched;
|
|
167
|
+
record(cwd, input.actor, {
|
|
168
|
+
type: "release.started",
|
|
169
|
+
version,
|
|
170
|
+
kind,
|
|
171
|
+
branch: branched.data.branch,
|
|
172
|
+
head: branched.data.baseSha,
|
|
173
|
+
base,
|
|
174
|
+
track: line.track,
|
|
175
|
+
tag,
|
|
176
|
+
});
|
|
177
|
+
return success({
|
|
178
|
+
version,
|
|
179
|
+
kind,
|
|
180
|
+
branch: branched.data.branch,
|
|
181
|
+
base,
|
|
182
|
+
baseSha: branched.data.baseSha,
|
|
183
|
+
track: line.track,
|
|
184
|
+
tag,
|
|
185
|
+
notes: branched.data.notes,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// finish: the plan
|
|
191
|
+
|
|
192
|
+
export type FinishState = {
|
|
193
|
+
merged: boolean;
|
|
194
|
+
tagged: boolean;
|
|
195
|
+
/** Merge-back branch -> already holds the tag. */
|
|
196
|
+
mergedBack: Record<string, boolean>;
|
|
197
|
+
branchDeleted: boolean;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type FinishPlan = {
|
|
201
|
+
version: string;
|
|
202
|
+
kind: ReleaseKind;
|
|
203
|
+
branch: string;
|
|
204
|
+
track: string | null;
|
|
205
|
+
tag: string;
|
|
206
|
+
production: string;
|
|
207
|
+
mergeBack: string[];
|
|
208
|
+
/** What the finish pushes: production and the merge-back branches, never a release branch. */
|
|
209
|
+
pushBranches: string[];
|
|
210
|
+
/** A hotfix's merge-back target when a release branch is open (git-flow), else null. */
|
|
211
|
+
openRelease: string | null;
|
|
212
|
+
/** git-flow merges locally; elsewhere finish opens a release PR. */
|
|
213
|
+
gitflow: boolean;
|
|
214
|
+
remote: string | null;
|
|
215
|
+
/** The release branch's tip (from the ledger once the branch is deleted). */
|
|
216
|
+
releaseTip: string;
|
|
217
|
+
/** The remote release branch's tip when it was published, else null. */
|
|
218
|
+
published: string | null;
|
|
219
|
+
/** The commit the tag marks: the production merge, once made. */
|
|
220
|
+
tagTarget: string | null;
|
|
221
|
+
/** A release.merged row exists (a merge resolved by hand has none). */
|
|
222
|
+
mergeRecorded: boolean;
|
|
223
|
+
state: FinishState;
|
|
224
|
+
/** The CHANGELOG.md section of this version, when there is one. */
|
|
225
|
+
changelog: string | null;
|
|
226
|
+
notes: string[];
|
|
227
|
+
/** Said before merging, never blocking (integration mode pr). */
|
|
228
|
+
warnings: string[];
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const KINDS: ReleaseKind[] = ["release", "hotfix"];
|
|
232
|
+
|
|
233
|
+
type Located = { line: ReleaseLine; kind: ReleaseKind; version: string; branch: string };
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Which release to finish: `version` (on the given or any line), else the
|
|
237
|
+
* checked-out release branch, else the newest started release this ledger
|
|
238
|
+
* has not finished, else the only release branch.
|
|
239
|
+
*/
|
|
240
|
+
function locate(cwd: string, version: string | null, track: string | null): ForgeResult<Located> {
|
|
241
|
+
const lines = releaseLinesOf(cwd).filter((line) => track === null || line.track === track);
|
|
242
|
+
if (!lines.length) return failure("invalid_input", `no release track ${JSON.stringify(track)}`);
|
|
243
|
+
const rows = releaseRows(cwd);
|
|
244
|
+
const started = rows.filter((row) => row.type === "release.started");
|
|
245
|
+
const finished = new Set(
|
|
246
|
+
rows.filter((row) => row.type === "release.finished").map((row) => String(row.branch)),
|
|
247
|
+
);
|
|
248
|
+
/** The started row of `branch`, newest first: it names the release's line. */
|
|
249
|
+
const startedRow = (branch: string) =>
|
|
250
|
+
started.findLast((row) => row.branch === branch && !finished.has(branch));
|
|
251
|
+
const lineOf = (branch: string, candidates: ReleaseLine[]): ReleaseLine => {
|
|
252
|
+
const row = startedRow(branch);
|
|
253
|
+
const byRow = row ? candidates.find((line) => line.track === (row.track ?? null)) : undefined;
|
|
254
|
+
if (byRow) return byRow;
|
|
255
|
+
const resolved = vcsConfig("resolve", cwd, { branch });
|
|
256
|
+
const name = resolved.ok === false ? null : (resolved.releaseTrack?.track?.name ?? null);
|
|
257
|
+
return candidates.find((line) => line.track === name) ?? candidates[0];
|
|
258
|
+
};
|
|
259
|
+
const match = (branch: string): Located | null => {
|
|
260
|
+
const hits: Array<{ line: ReleaseLine; kind: ReleaseKind; version: string }> = [];
|
|
261
|
+
for (const line of lines)
|
|
262
|
+
for (const kind of KINDS) {
|
|
263
|
+
const found = versionIn(templateOf(line, kind), branch);
|
|
264
|
+
if (found) hits.push({ line, kind, version: found });
|
|
265
|
+
}
|
|
266
|
+
if (!hits.length) return null;
|
|
267
|
+
const line = lineOf(
|
|
268
|
+
branch,
|
|
269
|
+
hits.map((hit) => hit.line),
|
|
270
|
+
);
|
|
271
|
+
const hit = hits.find((entry) => entry.line === line) ?? hits[0];
|
|
272
|
+
return { ...hit, branch };
|
|
273
|
+
};
|
|
274
|
+
if (version !== null) {
|
|
275
|
+
if (!validVersion(version))
|
|
276
|
+
return failure("invalid_input", `${JSON.stringify(version)} is not a version`);
|
|
277
|
+
for (const line of lines)
|
|
278
|
+
for (const kind of KINDS) {
|
|
279
|
+
const branch = branchFor(line, kind, version);
|
|
280
|
+
if (resolveRef(cwd, `refs/heads/${branch}`) || startedRow(branch)) {
|
|
281
|
+
const located = match(branch);
|
|
282
|
+
if (located) return success(located);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return failure(
|
|
286
|
+
"not_found",
|
|
287
|
+
`no release or hotfix branch for ${version} here, and none this ledger started`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const current = currentBranch(cwd);
|
|
291
|
+
const here = current ? match(current) : null;
|
|
292
|
+
if (here) return success(here);
|
|
293
|
+
const open = started.findLast((row) => !finished.has(String(row.branch)));
|
|
294
|
+
if (open) {
|
|
295
|
+
const located = match(String(open.branch));
|
|
296
|
+
if (located) return success(located);
|
|
297
|
+
}
|
|
298
|
+
const branches = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
299
|
+
.stdout.split("\n")
|
|
300
|
+
.map((name) => (name ? match(name) : null))
|
|
301
|
+
.filter((entry): entry is Located => entry !== null);
|
|
302
|
+
if (branches.length === 1) return success(branches[0]);
|
|
303
|
+
return failure(
|
|
304
|
+
branches.length ? "invalid_input" : "not_found",
|
|
305
|
+
branches.length
|
|
306
|
+
? `several releases are open (${branches.map((entry) => entry.branch).join(", ")}); name one`
|
|
307
|
+
: "no release or hotfix branch to finish",
|
|
308
|
+
branches.length
|
|
309
|
+
? `workit release finish ${branches[0].version}`
|
|
310
|
+
: "workit release start <version>",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isAncestor = (cwd: string, ancestor: string, descendant: string): boolean =>
|
|
315
|
+
git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The CHANGELOG.md section for `version` at `ref`: a `## [1.4.0]`, `## 1.4.0`
|
|
319
|
+
* or `# [1.4.0](…)` heading (a `v` before the version is fine), up to the next
|
|
320
|
+
* heading of the same or a higher level. Null without one.
|
|
321
|
+
*/
|
|
322
|
+
export function changelogSection(cwd: string, ref: string, version: string): string | null {
|
|
323
|
+
const shown = git(cwd, ["show", `${ref}:CHANGELOG.md`]);
|
|
324
|
+
if (!shown.ok) return null;
|
|
325
|
+
const lines = shown.stdout.split(/\r?\n/u);
|
|
326
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
327
|
+
const heading = new RegExp(`^(#{1,3})\\s+\\[?v?${escaped}\\]?(?:[\\s(]|$)`, "u");
|
|
328
|
+
const start = lines.findIndex((line) => heading.test(line));
|
|
329
|
+
if (start < 0) return null;
|
|
330
|
+
const level = heading.exec(lines[start])?.[1].length ?? 2;
|
|
331
|
+
let end = lines.length;
|
|
332
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
333
|
+
const hashes = /^(#+)\s/u.exec(lines[index])?.[1].length;
|
|
334
|
+
if (hashes !== undefined && hashes <= level) {
|
|
335
|
+
end = index;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const body = lines
|
|
340
|
+
.slice(start + 1, end)
|
|
341
|
+
.join("\n")
|
|
342
|
+
.trim();
|
|
343
|
+
return body || null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Preflight and state of a finish: nothing changes here except fetched remote-tracking refs. */
|
|
347
|
+
export function planReleaseFinish(
|
|
348
|
+
cwd: string,
|
|
349
|
+
input: { version?: string | null; track?: string | null },
|
|
350
|
+
): ForgeResult<FinishPlan> {
|
|
351
|
+
const progress = operationInProgress(cwd);
|
|
352
|
+
if (progress) return inProgressFailure(progress.operation, "finish the release");
|
|
353
|
+
const located = locate(cwd, input.version?.trim() || null, input.track ?? null);
|
|
354
|
+
if (!located.ok) return located;
|
|
355
|
+
const { line, kind, version, branch } = located.data;
|
|
356
|
+
const notes: string[] = [];
|
|
357
|
+
const dirty = git(cwd, ["status", "--porcelain", "--untracked-files=no"]).stdout.trim();
|
|
358
|
+
if (dirty)
|
|
359
|
+
return failure(
|
|
360
|
+
"blocked",
|
|
361
|
+
"dirty_worktree: tracked files have uncommitted changes; a release finish merges on a clean tree",
|
|
362
|
+
"workit git commit -m '<msg>' -- <paths> # on the release branch, or stash them",
|
|
363
|
+
);
|
|
364
|
+
const tag = `${line.tagPrefix}${version}`;
|
|
365
|
+
const rows = releaseRows(cwd).filter((row) => row.branch === branch || row.release === branch);
|
|
366
|
+
// git-flow: a hotfix finished while a release is open merges back into
|
|
367
|
+
// that release branch instead of develop (the release carries it there).
|
|
368
|
+
// Recorded once, so a resumed finish keeps the same target.
|
|
369
|
+
const recordedOpen = rows.findLast((row) => row.type === "release.hotfix_target");
|
|
370
|
+
const openRelease =
|
|
371
|
+
kind !== "hotfix"
|
|
372
|
+
? null
|
|
373
|
+
: typeof recordedOpen?.target === "string"
|
|
374
|
+
? recordedOpen.target
|
|
375
|
+
: (git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
376
|
+
.stdout.split("\n")
|
|
377
|
+
.find((name) => name && versionIn(templateOf(line, "release"), name) !== null) ?? null);
|
|
378
|
+
const mergeBack = openRelease
|
|
379
|
+
? [...new Set(line.mergeBack.map((name) => (name === line.integration ? openRelease : name)))]
|
|
380
|
+
: line.mergeBack;
|
|
381
|
+
if (openRelease)
|
|
382
|
+
notes.push(
|
|
383
|
+
`${openRelease} is open: the hotfix merges back into it instead of ${line.integration}`,
|
|
384
|
+
);
|
|
385
|
+
const remote = pushRemoteName(cwd, line.production);
|
|
386
|
+
if (remote) {
|
|
387
|
+
const wanted = [line.production, ...mergeBack, branch];
|
|
388
|
+
for (const name of wanted)
|
|
389
|
+
fetchRefs(cwd, remote, [`+refs/heads/${name}:refs/remotes/${remote}/${name}`]);
|
|
390
|
+
}
|
|
391
|
+
const branchTip = resolveRef(cwd, `refs/heads/${branch}`);
|
|
392
|
+
const remoteBranch = remote ? resolveRef(cwd, `refs/remotes/${remote}/${branch}`) : null;
|
|
393
|
+
const mergedRow = rows.findLast((row) => row.type === "release.merged");
|
|
394
|
+
const deletedRow = rows.findLast((row) => row.type === "release.branch_deleted");
|
|
395
|
+
// Once the branch is gone its tip is in the ledger: the recorded merge, or
|
|
396
|
+
// the deletion (a merge resolved by hand left no release.merged row).
|
|
397
|
+
const recordedTip = [mergedRow?.releaseTip, deletedRow?.head].find(
|
|
398
|
+
(value): value is string => typeof value === "string",
|
|
399
|
+
);
|
|
400
|
+
const releaseTip = branchTip ?? recordedTip ?? null;
|
|
401
|
+
if (!releaseTip)
|
|
402
|
+
return failure(
|
|
403
|
+
"not_found",
|
|
404
|
+
`${branch} is gone and this ledger did not record its merge; nothing to resume`,
|
|
405
|
+
);
|
|
406
|
+
// Production and every merge-back branch: local, not behind the remote,
|
|
407
|
+
// and ahead of it only by this release's own merges.
|
|
408
|
+
for (const name of [line.production, ...mergeBack]) {
|
|
409
|
+
const local = resolveRef(cwd, `refs/heads/${name}`);
|
|
410
|
+
const tracked = remote ? resolveRef(cwd, `refs/remotes/${remote}/${name}`) : null;
|
|
411
|
+
if (!local && tracked) {
|
|
412
|
+
const created = git(cwd, ["branch", "--no-track", name, tracked]);
|
|
413
|
+
if (!created.ok)
|
|
414
|
+
return failure("failed", `could not create local ${name}: ${gitError(created)}`);
|
|
415
|
+
notes.push(`created local ${name} from ${remote}/${name}`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (!local)
|
|
419
|
+
return failure("not_found", `${name} exists neither locally nor on ${remote ?? "a remote"}`);
|
|
420
|
+
if (!tracked || name === openRelease) continue;
|
|
421
|
+
if (!isAncestor(cwd, tracked, local))
|
|
422
|
+
return failure(
|
|
423
|
+
"blocked",
|
|
424
|
+
`branch_behind: local ${name} is behind or diverged from ${remote}/${name}; git-flow finishes only onto up-to-date branches`,
|
|
425
|
+
`git switch ${name} && git merge --ff-only ${remote}/${name} # then: workit release finish ${version}`,
|
|
426
|
+
);
|
|
427
|
+
// Published production commits arrive through the merge-back; not new.
|
|
428
|
+
const productionTracked = remote
|
|
429
|
+
? resolveRef(cwd, `refs/remotes/${remote}/${line.production}`)
|
|
430
|
+
: null;
|
|
431
|
+
const foreign = git(cwd, [
|
|
432
|
+
"rev-list",
|
|
433
|
+
local,
|
|
434
|
+
`^${tracked}`,
|
|
435
|
+
`^${releaseTip}`,
|
|
436
|
+
...(productionTracked ? [`^${productionTracked}`] : []),
|
|
437
|
+
])
|
|
438
|
+
.stdout.split("\n")
|
|
439
|
+
.filter(Boolean)
|
|
440
|
+
.filter(
|
|
441
|
+
(sha) =>
|
|
442
|
+
!(
|
|
443
|
+
git(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok &&
|
|
444
|
+
isAncestor(cwd, releaseTip, sha)
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
if (foreign.length)
|
|
448
|
+
return failure(
|
|
449
|
+
"blocked",
|
|
450
|
+
`branch_ahead: local ${name} has ${foreign.length} commit(s) ${remote}/${name} lacks that are not part of ${branch}; the finish push would publish them`,
|
|
451
|
+
`land them through review first, or: git switch ${name} && git reset --keep ${remote}/${name} # then: workit release finish ${version}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (branchTip && remoteBranch && !isAncestor(cwd, remoteBranch, branchTip))
|
|
455
|
+
return failure(
|
|
456
|
+
"blocked",
|
|
457
|
+
`release_behind: ${remote}/${branch} has commits local ${branch} lacks`,
|
|
458
|
+
`git switch ${branch} && git merge --ff-only ${remote}/${branch}`,
|
|
459
|
+
);
|
|
460
|
+
const production = resolveRef(cwd, `refs/heads/${line.production}`) as string;
|
|
461
|
+
const merged = isAncestor(cwd, releaseTip, production);
|
|
462
|
+
const localTag = resolveRef(cwd, `refs/tags/${tag}`);
|
|
463
|
+
if (localTag && !(isAncestor(cwd, localTag, production) && isAncestor(cwd, releaseTip, localTag)))
|
|
464
|
+
return failure(
|
|
465
|
+
"blocked",
|
|
466
|
+
`tag_exists: ${tag} (${localTag.slice(0, 12)}) is not this release's merge on ${line.production}`,
|
|
467
|
+
"a release tag never moves; finish under the next version",
|
|
468
|
+
);
|
|
469
|
+
if (remote) {
|
|
470
|
+
const remoteTag = remoteRefTip(cwd, remote, `refs/tags/${tag}`);
|
|
471
|
+
if (remoteTag.ok && remoteTag.sha && remoteTag.sha !== localTag)
|
|
472
|
+
return failure(
|
|
473
|
+
"blocked",
|
|
474
|
+
`tag_exists: ${remote} already has ${tag} at ${remoteTag.sha.slice(0, 12)}`,
|
|
475
|
+
"a release tag never moves; finish under the next version",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const recordedMerge = typeof mergedRow?.merge === "string" ? mergedRow.merge : null;
|
|
479
|
+
const tagTarget = localTag ?? (merged ? (recordedMerge ?? production) : null);
|
|
480
|
+
const state: FinishState = {
|
|
481
|
+
merged,
|
|
482
|
+
tagged: localTag !== null,
|
|
483
|
+
mergedBack: Object.fromEntries(
|
|
484
|
+
mergeBack.map((name) => [
|
|
485
|
+
name,
|
|
486
|
+
localTag !== null &&
|
|
487
|
+
isAncestor(cwd, localTag, resolveRef(cwd, `refs/heads/${name}`) as string),
|
|
488
|
+
]),
|
|
489
|
+
),
|
|
490
|
+
branchDeleted: branchTip === null,
|
|
491
|
+
};
|
|
492
|
+
return success({
|
|
493
|
+
version,
|
|
494
|
+
kind,
|
|
495
|
+
branch,
|
|
496
|
+
track: line.track,
|
|
497
|
+
tag,
|
|
498
|
+
production: line.production,
|
|
499
|
+
mergeBack,
|
|
500
|
+
pushBranches: [line.production, ...mergeBack.filter((name) => name !== openRelease)],
|
|
501
|
+
openRelease,
|
|
502
|
+
gitflow: line.gitflow,
|
|
503
|
+
remote,
|
|
504
|
+
releaseTip,
|
|
505
|
+
published: remoteBranch,
|
|
506
|
+
tagTarget,
|
|
507
|
+
mergeRecorded: mergedRow !== undefined,
|
|
508
|
+
state,
|
|
509
|
+
changelog: changelogSection(cwd, branchTip ?? production, version),
|
|
510
|
+
notes,
|
|
511
|
+
warnings: integrationPrWarning(cwd, line.production),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A workspace that sets `integration: "pr"` lands work through reviewed PRs;
|
|
517
|
+
* its forge may also protect production against the finish push. Said, not
|
|
518
|
+
* enforced: git-flow finishes locally either way.
|
|
519
|
+
*/
|
|
520
|
+
function integrationPrWarning(cwd: string, production: string): string[] {
|
|
521
|
+
try {
|
|
522
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
523
|
+
cwd,
|
|
524
|
+
"branch",
|
|
525
|
+
process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
526
|
+
).policy as { integration?: unknown } | undefined;
|
|
527
|
+
return selected?.integration === "pr"
|
|
528
|
+
? [
|
|
529
|
+
`the workspace integration mode is pr: the remote may require pull requests on ${production}, which would reject the finish push`,
|
|
530
|
+
]
|
|
531
|
+
: [];
|
|
532
|
+
} catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// finish: the local merges
|
|
539
|
+
|
|
540
|
+
export type LocalFinish = {
|
|
541
|
+
/** Steps run now (the rest were already done). */
|
|
542
|
+
ran: string[];
|
|
543
|
+
tagTarget: string;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const switchTo = (cwd: string, branch: string): ForgeResult<void> => {
|
|
547
|
+
if (currentBranch(cwd) === branch) return success(undefined);
|
|
548
|
+
const switched = git(cwd, ["switch", "-q", branch]);
|
|
549
|
+
return switched.ok
|
|
550
|
+
? success(undefined)
|
|
551
|
+
: failure("failed", `git switch ${branch} failed: ${gitError(switched)}`);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/** A --no-ff merge of `what` into the checked-out branch; a conflict stops with the resume command. */
|
|
555
|
+
const mergeNoFf = (
|
|
556
|
+
cwd: string,
|
|
557
|
+
what: string,
|
|
558
|
+
into: string,
|
|
559
|
+
version: string,
|
|
560
|
+
): ForgeResult<string> => {
|
|
561
|
+
const merged = git(cwd, ["merge", "--no-ff", "--no-edit", what], { timeoutMs: 10 * 60_000 });
|
|
562
|
+
if (!merged.ok) {
|
|
563
|
+
const conflicted = git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).ok;
|
|
564
|
+
return failure(
|
|
565
|
+
"failed",
|
|
566
|
+
conflicted
|
|
567
|
+
? `merge_conflict: merging ${what} into ${into} conflicts`
|
|
568
|
+
: `git merge ${what} into ${into} failed: ${gitError(merged)}`,
|
|
569
|
+
conflicted
|
|
570
|
+
? `resolve the conflicts, git add <files>, git merge --continue, then: workit release finish ${version}`
|
|
571
|
+
: undefined,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
return success(git(cwd, ["rev-parse", "HEAD"]).stdout.trim());
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The local half of `git flow release finish`, each step skipped when the
|
|
579
|
+
* plan's state says it is done and recorded as a ledger row when run.
|
|
580
|
+
*/
|
|
581
|
+
export function finishLocally(
|
|
582
|
+
cwd: string,
|
|
583
|
+
plan: FinishPlan,
|
|
584
|
+
actor: LedgerActor,
|
|
585
|
+
): ForgeResult<LocalFinish> {
|
|
586
|
+
const ran: string[] = [];
|
|
587
|
+
const base = { version: plan.version, release: plan.branch, tag: plan.tag, track: plan.track };
|
|
588
|
+
let tagTarget = plan.tagTarget;
|
|
589
|
+
// Once per release: a resumed finish reads the target back from this row.
|
|
590
|
+
if (
|
|
591
|
+
plan.openRelease &&
|
|
592
|
+
!releaseRows(cwd).some(
|
|
593
|
+
(row) => row.type === "release.hotfix_target" && row.release === plan.branch,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
record(cwd, actor, {
|
|
597
|
+
type: "release.hotfix_target",
|
|
598
|
+
...base,
|
|
599
|
+
branch: plan.branch,
|
|
600
|
+
target: plan.openRelease,
|
|
601
|
+
});
|
|
602
|
+
// A merge resolved by hand (`git merge --continue`) left no row: record
|
|
603
|
+
// what the repository shows, so a resume after the branch is gone has its tip.
|
|
604
|
+
if (plan.state.merged && !plan.mergeRecorded && tagTarget)
|
|
605
|
+
record(cwd, actor, {
|
|
606
|
+
type: "release.merged",
|
|
607
|
+
...base,
|
|
608
|
+
branch: plan.production,
|
|
609
|
+
head: tagTarget,
|
|
610
|
+
merge: tagTarget,
|
|
611
|
+
releaseTip: plan.releaseTip,
|
|
612
|
+
observed: true,
|
|
613
|
+
});
|
|
614
|
+
if (!plan.state.merged) {
|
|
615
|
+
const on = switchTo(cwd, plan.production);
|
|
616
|
+
if (!on.ok) return on;
|
|
617
|
+
const merged = mergeNoFf(cwd, plan.branch, plan.production, plan.version);
|
|
618
|
+
if (!merged.ok) return merged;
|
|
619
|
+
tagTarget = merged.data;
|
|
620
|
+
ran.push("merge");
|
|
621
|
+
record(cwd, actor, {
|
|
622
|
+
type: "release.merged",
|
|
623
|
+
...base,
|
|
624
|
+
branch: plan.production,
|
|
625
|
+
head: merged.data,
|
|
626
|
+
merge: merged.data,
|
|
627
|
+
releaseTip: plan.releaseTip,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (!tagTarget) return failure("failed", `cannot tell which ${plan.production} commit to tag`);
|
|
631
|
+
if (!plan.state.tagged) {
|
|
632
|
+
const message = plan.changelog ?? `Release ${plan.version}`;
|
|
633
|
+
// verbatim: a changelog's `### Fixed` is not a comment to strip.
|
|
634
|
+
const tagged = git(cwd, [
|
|
635
|
+
"tag",
|
|
636
|
+
"-a",
|
|
637
|
+
"--cleanup=verbatim",
|
|
638
|
+
plan.tag,
|
|
639
|
+
"-m",
|
|
640
|
+
message,
|
|
641
|
+
tagTarget,
|
|
642
|
+
]);
|
|
643
|
+
if (!tagged.ok) return failure("failed", `git tag ${plan.tag} failed: ${gitError(tagged)}`);
|
|
644
|
+
ran.push("tag");
|
|
645
|
+
record(cwd, actor, {
|
|
646
|
+
type: "release.tagged",
|
|
647
|
+
...base,
|
|
648
|
+
branch: plan.production,
|
|
649
|
+
head: tagTarget,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
for (const name of plan.mergeBack) {
|
|
653
|
+
if (plan.state.mergedBack[name]) continue;
|
|
654
|
+
const on = switchTo(cwd, name);
|
|
655
|
+
if (!on.ok) return on;
|
|
656
|
+
const merged = mergeNoFf(cwd, plan.tag, name, plan.version);
|
|
657
|
+
if (!merged.ok) return merged;
|
|
658
|
+
ran.push(`merge-back ${name}`);
|
|
659
|
+
record(cwd, actor, { type: "release.merged_back", ...base, branch: name, head: merged.data });
|
|
660
|
+
}
|
|
661
|
+
if (!plan.state.branchDeleted) {
|
|
662
|
+
if (currentBranch(cwd) === plan.branch) {
|
|
663
|
+
const on = switchTo(cwd, plan.mergeBack.at(-1) ?? plan.production);
|
|
664
|
+
if (!on.ok) return on;
|
|
665
|
+
}
|
|
666
|
+
// -d, not -D: git refuses when the release has commits nothing merged.
|
|
667
|
+
const deleted = git(cwd, ["branch", "-d", plan.branch]);
|
|
668
|
+
// A published branch is checked against its upstream: commits made
|
|
669
|
+
// after publishing would be lost with it.
|
|
670
|
+
if (!deleted.ok)
|
|
671
|
+
return failure(
|
|
672
|
+
"failed",
|
|
673
|
+
`git branch -d ${plan.branch} failed: ${gitError(deleted)}`,
|
|
674
|
+
plan.published
|
|
675
|
+
? `workit release publish ${plan.version} # its remote copy lacks commits; then: workit release finish ${plan.version}`
|
|
676
|
+
: undefined,
|
|
677
|
+
);
|
|
678
|
+
ran.push("delete-branch");
|
|
679
|
+
record(cwd, actor, {
|
|
680
|
+
type: "release.branch_deleted",
|
|
681
|
+
...base,
|
|
682
|
+
branch: plan.branch,
|
|
683
|
+
head: plan.releaseTip,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return success({ ran, tagTarget });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Check out the release branch (the release PR path pushes it from there). */
|
|
690
|
+
export const switchToRelease = (cwd: string, plan: FinishPlan): ForgeResult<void> =>
|
|
691
|
+
switchTo(cwd, plan.branch);
|
|
692
|
+
|
|
693
|
+
/** A ledger row for a finish step the CLI ran (push, forge release, done). */
|
|
694
|
+
export function recordReleaseStep(
|
|
695
|
+
cwd: string,
|
|
696
|
+
actor: LedgerActor,
|
|
697
|
+
plan: FinishPlan,
|
|
698
|
+
row: ReleaseRow,
|
|
699
|
+
): void {
|
|
700
|
+
record(cwd, actor, {
|
|
701
|
+
version: plan.version,
|
|
702
|
+
release: plan.branch,
|
|
703
|
+
tag: plan.tag,
|
|
704
|
+
track: plan.track,
|
|
705
|
+
...row,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// publish
|
|
711
|
+
|
|
712
|
+
/** The release branch `workit release publish` pushes: the named version's, else the checkout's. */
|
|
713
|
+
export function releaseToPublish(cwd: string, version: string | null): ForgeResult<Located> {
|
|
714
|
+
const located = locate(cwd, version, null);
|
|
715
|
+
if (!located.ok) return located;
|
|
716
|
+
if (!resolveRef(cwd, `refs/heads/${located.data.branch}`))
|
|
717
|
+
return failure("not_found", `${located.data.branch} is not a local branch`);
|
|
718
|
+
return located;
|
|
719
|
+
}
|