@brainervirus/workit-core 8.12.3 → 8.13.1
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/scripts/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/references/orchestration.md +4 -3
- package/skills/workit-review/SKILL.md +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/hygiene.ts +37 -1
- package/src/core/methods.ts +12 -11
- package/src/core/store-lock.ts +9 -1
- package/src/forge/github.ts +30 -0
- package/src/forge/gitlab.ts +33 -0
- package/src/forge/pr-ops.ts +84 -18
- package/src/forge/redact.ts +6 -2
- package/src/forge/resolve.ts +15 -0
- package/src/forge/types.ts +10 -0
- package/src/git/ops.ts +181 -46
- package/src/git/release.ts +719 -0
- package/src/hooks/handle.ts +6 -3
- package/src/hooks/raw-git.ts +7 -5
- package/src/hooks/stop.ts +4 -2
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/src/git/ops.ts
CHANGED
|
@@ -37,13 +37,19 @@ import {
|
|
|
37
37
|
type BranchKind,
|
|
38
38
|
type FlowRoute,
|
|
39
39
|
} from "../core/branch";
|
|
40
|
+
import { releaseTagPrefixOf } from "../core/branch-policy";
|
|
40
41
|
import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
|
|
41
42
|
import { pushTargetIsStable } from "../core/pr-create";
|
|
42
|
-
import { recordBranchBase, trackBranchName } from "../core/release-tracks";
|
|
43
|
+
import { readReleaseTracks, recordBranchBase, trackBranchName } from "../core/release-tracks";
|
|
43
44
|
import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
|
|
44
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
resolveRuntimeReleaseTracks,
|
|
47
|
+
resolveRuntimeWorkspacePolicy,
|
|
48
|
+
workspaceTrailers,
|
|
49
|
+
} from "../core/workspaces";
|
|
45
50
|
import { redactText } from "../forge/redact";
|
|
46
51
|
import { failure, success, type ForgeResult } from "../forge/types";
|
|
52
|
+
import { issuedBinding, sessionClaimRefusal } from "../host-session";
|
|
47
53
|
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
48
54
|
import {
|
|
49
55
|
GIT_TIMEOUTS,
|
|
@@ -71,7 +77,7 @@ type Run = {
|
|
|
71
77
|
stderr: string;
|
|
72
78
|
};
|
|
73
79
|
|
|
74
|
-
const git = (
|
|
80
|
+
export const git = (
|
|
75
81
|
cwd: string,
|
|
76
82
|
args: string[],
|
|
77
83
|
options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {},
|
|
@@ -97,7 +103,7 @@ const git = (
|
|
|
97
103
|
};
|
|
98
104
|
|
|
99
105
|
/** The last meaningful lines of git's stderr, redacted, for an error message. */
|
|
100
|
-
const gitError = (run: { stderr: string; stdout?: string }, max = 3): string => {
|
|
106
|
+
export const gitError = (run: { stderr: string; stdout?: string }, max = 3): string => {
|
|
101
107
|
const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
|
|
102
108
|
.split(/\r?\n/u)
|
|
103
109
|
.map((value) => value.trim())
|
|
@@ -147,7 +153,7 @@ type InProgress = {
|
|
|
147
153
|
};
|
|
148
154
|
|
|
149
155
|
/** The multi-step git operation the checkout is in the middle of, if any. */
|
|
150
|
-
function operationInProgress(cwd: string): InProgress | null {
|
|
156
|
+
export function operationInProgress(cwd: string): InProgress | null {
|
|
151
157
|
const markers: [string, Operation][] = [
|
|
152
158
|
["rebase-merge", "rebase"],
|
|
153
159
|
["rebase-apply", "rebase"],
|
|
@@ -186,7 +192,7 @@ function operationInProgress(cwd: string): InProgress | null {
|
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
/** A refusal that names the operation in progress and how to finish or leave it. */
|
|
189
|
-
function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
|
|
195
|
+
export function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
|
|
190
196
|
const command = operation === "am" ? "git am" : `git ${operation}`;
|
|
191
197
|
return failure(
|
|
192
198
|
"blocked",
|
|
@@ -644,6 +650,25 @@ export function gitCommit(
|
|
|
644
650
|
"invalid_input",
|
|
645
651
|
"WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
|
|
646
652
|
);
|
|
653
|
+
// A commit names its author session (commit.recorded, the trailer): the
|
|
654
|
+
// same claim check as a verdict, and an id issued to a verifier or
|
|
655
|
+
// reviewer never authors code (audit mv83zyg6 P2b).
|
|
656
|
+
if (session) {
|
|
657
|
+
const env = input.env ?? process.env;
|
|
658
|
+
if (issuedBinding(cwd, session) !== null)
|
|
659
|
+
return failure(
|
|
660
|
+
"blocked",
|
|
661
|
+
`judge_session: ${session} was issued to a verifier or reviewer subagent; it records verdicts and never authors commits`,
|
|
662
|
+
"commit from the implementer's or the lead's own session (unset WORKIT_SESSION_ID back to the shell's)",
|
|
663
|
+
);
|
|
664
|
+
const refusal = sessionClaimRefusal(env, session, cwd);
|
|
665
|
+
if (refusal)
|
|
666
|
+
return failure(
|
|
667
|
+
"blocked",
|
|
668
|
+
refusal.error,
|
|
669
|
+
"commit from the session this shell really is: never set WORKIT_SESSION_ID by hand for a commit (unset it, or use the id the host put in this shell)",
|
|
670
|
+
);
|
|
671
|
+
}
|
|
647
672
|
// A commit that only rewords (amend) or is empty on purpose needs no change.
|
|
648
673
|
const changeRequired = !input.amend && !input.allowEmpty;
|
|
649
674
|
|
|
@@ -1130,31 +1155,116 @@ export type ReleaseRef = {
|
|
|
1130
1155
|
ref: string;
|
|
1131
1156
|
/** The commit it points at (a tag peeled). */
|
|
1132
1157
|
sha: string;
|
|
1158
|
+
/** A tag's line production branch: the tag's commit must be on it. */
|
|
1159
|
+
production?: string | null;
|
|
1133
1160
|
};
|
|
1134
1161
|
|
|
1135
1162
|
export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
|
|
1136
1163
|
refs: ReleaseRef[];
|
|
1137
|
-
/** The
|
|
1164
|
+
/** The workspace's production branch (git-flow production, else the default target). */
|
|
1138
1165
|
production: string | null;
|
|
1139
1166
|
};
|
|
1140
1167
|
|
|
1141
1168
|
/**
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1169
|
+
* One release line: the branches a release or hotfix finishes onto. The
|
|
1170
|
+
* workspace's own (git-flow production and develop, else the trunk) or a
|
|
1171
|
+
* release track's.
|
|
1144
1172
|
*/
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1173
|
+
export type ReleaseLine = {
|
|
1174
|
+
/** The release track's name; null for the workspace's own branches. */
|
|
1175
|
+
track: string | null;
|
|
1176
|
+
production: string;
|
|
1177
|
+
integration: string;
|
|
1178
|
+
/** Where a finished release merges back (git-flow: develop). */
|
|
1179
|
+
mergeBack: string[];
|
|
1180
|
+
/** The release tag prefix: the track's tagNamespace, then the workspace prefix. */
|
|
1181
|
+
tagPrefix: string;
|
|
1182
|
+
/** git-flow merges a release locally; elsewhere the trunk moves only through a PR. */
|
|
1183
|
+
gitflow: boolean;
|
|
1184
|
+
/** A track's branch-name templates (`release/nun-{version}`). */
|
|
1185
|
+
naming: { release?: string; hotfix?: string };
|
|
1148
1186
|
};
|
|
1149
1187
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1188
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
1189
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* The workspace's release lines: its own first, then one per release track
|
|
1193
|
+
* (core/release-tracks.ts reads them leniently). The tag prefix is the
|
|
1194
|
+
* branch policy's (releaseTagPrefixOf); a track adds its `tagNamespace`. A
|
|
1195
|
+
* track's `strategy`, else the workspace preset, decides whether it is git-flow.
|
|
1196
|
+
*/
|
|
1197
|
+
export function releaseLinesOf(cwd: string): ReleaseLine[] {
|
|
1198
|
+
const { preset, branches, production } = releaseBranchesOf(cwd);
|
|
1199
|
+
let policyPrefix: unknown;
|
|
1200
|
+
try {
|
|
1201
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
1202
|
+
cwd,
|
|
1203
|
+
"branch",
|
|
1204
|
+
process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
1205
|
+
).policy as Record<string, unknown> | undefined;
|
|
1206
|
+
policyPrefix = selected?.releaseTagPrefix;
|
|
1207
|
+
} catch {
|
|
1208
|
+
policyPrefix = undefined;
|
|
1209
|
+
}
|
|
1210
|
+
const prefix = releaseTagPrefixOf(cwd, policyPrefix);
|
|
1211
|
+
const trunk = production ?? "main";
|
|
1212
|
+
const lines: ReleaseLine[] = [
|
|
1213
|
+
{
|
|
1214
|
+
track: null,
|
|
1215
|
+
production: branches[0] ?? trunk,
|
|
1216
|
+
integration: branches[1] ?? trunk,
|
|
1217
|
+
mergeBack: branches[1] ? [branches[1]] : [],
|
|
1218
|
+
tagPrefix: prefix,
|
|
1219
|
+
gitflow: branches.length > 0,
|
|
1220
|
+
naming: {},
|
|
1221
|
+
},
|
|
1222
|
+
];
|
|
1223
|
+
let raw: unknown;
|
|
1224
|
+
try {
|
|
1225
|
+
raw = resolveRuntimeReleaseTracks(cwd)?.releaseTracks;
|
|
1226
|
+
} catch {
|
|
1227
|
+
return lines;
|
|
1228
|
+
}
|
|
1229
|
+
for (const track of readReleaseTracks(raw).tracks) {
|
|
1230
|
+
const value = isRecord(raw) ? raw[track.name] : undefined;
|
|
1231
|
+
const entry: Record<string, unknown> = isRecord(value) ? value : {};
|
|
1232
|
+
const strategy = typeof entry.strategy === "string" ? entry.strategy : preset;
|
|
1233
|
+
const namespace = typeof entry.tagNamespace === "string" ? entry.tagNamespace.trim() : "";
|
|
1234
|
+
lines.push({
|
|
1235
|
+
track: track.name,
|
|
1236
|
+
production: track.productionBranch,
|
|
1237
|
+
integration: track.integrationBranch,
|
|
1238
|
+
// git-flow always merges a release back into the integration branch.
|
|
1239
|
+
mergeBack: track.mergeBackBranches.length
|
|
1240
|
+
? track.mergeBackBranches
|
|
1241
|
+
: [track.integrationBranch],
|
|
1242
|
+
tagPrefix: `${namespace}${prefix}`,
|
|
1243
|
+
gitflow: strategy === "gitflow",
|
|
1244
|
+
naming: { release: track.naming.release, hotfix: track.naming.hotfix },
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
return lines;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* The line a release tag belongs to: its prefix and a digit (`v4.1.0`,
|
|
1252
|
+
* `1.4.0` with an empty prefix, `nun/v2`); the longest matching prefix wins.
|
|
1253
|
+
* Null for any other tag.
|
|
1254
|
+
*/
|
|
1255
|
+
const lineOfTag = (tag: string, lines: readonly ReleaseLine[]): ReleaseLine | null =>
|
|
1256
|
+
lines
|
|
1257
|
+
.filter(
|
|
1258
|
+
(line) => tag.startsWith(line.tagPrefix) && /^\d/u.test(tag.slice(line.tagPrefix.length)),
|
|
1259
|
+
)
|
|
1260
|
+
.toSorted((a, b) => b.tagPrefix.length - a.tagPrefix.length)[0] ?? null;
|
|
1153
1261
|
|
|
1154
1262
|
/**
|
|
1155
1263
|
* What `workit git push --release` may push: the git-flow production and
|
|
1156
|
-
* develop branches and
|
|
1157
|
-
*
|
|
1264
|
+
* develop branches (and a git-flow release track's production, integration
|
|
1265
|
+
* and merge-back branches) and release tags (the policy's tag prefix and a
|
|
1266
|
+
* digit), nothing else, each by its local ref. Everything else stays on the
|
|
1267
|
+
* feature-branch path (`workit git push`, `workit pr create`).
|
|
1158
1268
|
*/
|
|
1159
1269
|
export function releasePushPreflight(
|
|
1160
1270
|
cwd: string,
|
|
@@ -1164,7 +1274,16 @@ export function releasePushPreflight(
|
|
|
1164
1274
|
if (progress) return inProgressFailure(progress.operation, "push");
|
|
1165
1275
|
const wanted = [...new Set(names)];
|
|
1166
1276
|
if (!wanted.length) return failure("invalid_input", "name the release refs to push");
|
|
1167
|
-
const { preset,
|
|
1277
|
+
const { preset, production } = releaseBranchesOf(cwd);
|
|
1278
|
+
const lines = releaseLinesOf(cwd);
|
|
1279
|
+
const tracks = lines.filter((line) => line.track !== null && line.gitflow);
|
|
1280
|
+
const branches = [
|
|
1281
|
+
...new Set([
|
|
1282
|
+
...releaseBranchesOf(cwd).branches,
|
|
1283
|
+
...tracks.flatMap((line) => [line.production, line.integration, ...line.mergeBack]),
|
|
1284
|
+
]),
|
|
1285
|
+
];
|
|
1286
|
+
const tagShape = `${lines[0].tagPrefix}<version>`;
|
|
1168
1287
|
const refs: ReleaseRef[] = [];
|
|
1169
1288
|
for (const raw of wanted) {
|
|
1170
1289
|
if (raw.startsWith("-") || raw.includes(":"))
|
|
@@ -1182,14 +1301,20 @@ export function releasePushPreflight(
|
|
|
1182
1301
|
`workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
|
|
1183
1302
|
);
|
|
1184
1303
|
if (tag) {
|
|
1185
|
-
const
|
|
1186
|
-
if (!
|
|
1304
|
+
const line = lineOfTag(tagName, lines);
|
|
1305
|
+
if (!line)
|
|
1187
1306
|
return failure(
|
|
1188
1307
|
"blocked",
|
|
1189
|
-
`release_push_refused: tag ${tagName} is not a release tag (${
|
|
1190
|
-
`git tag -a ${
|
|
1308
|
+
`release_push_refused: tag ${tagName} is not a release tag (${tagShape})`,
|
|
1309
|
+
`git tag -a ${tagShape} -m <message> # then: workit git push --release ${tagShape}`,
|
|
1191
1310
|
);
|
|
1192
|
-
refs.push({
|
|
1311
|
+
refs.push({
|
|
1312
|
+
kind: "tag",
|
|
1313
|
+
name: tagName,
|
|
1314
|
+
ref: `refs/tags/${tagName}`,
|
|
1315
|
+
sha: tag,
|
|
1316
|
+
production: line.track === null ? production : line.production,
|
|
1317
|
+
});
|
|
1193
1318
|
continue;
|
|
1194
1319
|
}
|
|
1195
1320
|
if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
|
|
@@ -1198,10 +1323,10 @@ export function releasePushPreflight(
|
|
|
1198
1323
|
"blocked",
|
|
1199
1324
|
branches.length
|
|
1200
1325
|
? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
|
|
1201
|
-
: `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes
|
|
1326
|
+
: `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes ${tagShape} tags there, not branches`,
|
|
1202
1327
|
branches.length
|
|
1203
1328
|
? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
|
|
1204
|
-
: `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release
|
|
1329
|
+
: `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release ${tagShape}`,
|
|
1205
1330
|
);
|
|
1206
1331
|
refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
|
|
1207
1332
|
}
|
|
@@ -1226,7 +1351,14 @@ export function releasePushPreflight(
|
|
|
1226
1351
|
`push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
|
|
1227
1352
|
"git config --get-regexp '^url\\.' # remove the rewrite for this remote",
|
|
1228
1353
|
);
|
|
1229
|
-
return success({
|
|
1354
|
+
return success({
|
|
1355
|
+
refs,
|
|
1356
|
+
remote,
|
|
1357
|
+
url: redactRemote(raw),
|
|
1358
|
+
rawUrl: raw,
|
|
1359
|
+
local,
|
|
1360
|
+
production,
|
|
1361
|
+
});
|
|
1230
1362
|
}
|
|
1231
1363
|
|
|
1232
1364
|
export type ReleasePushOutcome = {
|
|
@@ -1271,29 +1403,32 @@ export function executeReleasePush(
|
|
|
1271
1403
|
`git fetch ${plan.remote} ${ref.name} && git switch ${ref.name} && git merge --ff-only ${plan.remote}/${ref.name} # then: workit git push --release ${ref.name}`,
|
|
1272
1404
|
);
|
|
1273
1405
|
}
|
|
1274
|
-
// A release tag marks a commit
|
|
1275
|
-
//
|
|
1406
|
+
// A release tag marks a commit its line's production branch already has
|
|
1407
|
+
// (on the remote, or as this push moves it): never an unmerged feature
|
|
1408
|
+
// commit, and never another line's production (a nun/ tag on master).
|
|
1276
1409
|
const tags = plan.refs.filter((ref) => ref.kind === "tag");
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1410
|
+
const contains = (tip: string, sha: string) =>
|
|
1411
|
+
git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
|
|
1412
|
+
for (const tag of tags) {
|
|
1413
|
+
const production = tag.production ?? plan.production;
|
|
1414
|
+
let remoteTip: string | null = null;
|
|
1415
|
+
if (production) {
|
|
1416
|
+
const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${production}`);
|
|
1281
1417
|
if (!tip.ok) return failure(tip.code, tip.error);
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
const contains = (tip: string, sha: string) =>
|
|
1285
|
-
git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
|
|
1286
|
-
for (const tag of tags) {
|
|
1287
|
-
const released =
|
|
1288
|
-
(productionTip !== null && contains(productionTip, tag.sha)) ||
|
|
1289
|
-
plan.refs.some((ref) => ref.kind === "branch" && contains(ref.sha, tag.sha));
|
|
1290
|
-
if (!released)
|
|
1291
|
-
return failure(
|
|
1292
|
-
"blocked",
|
|
1293
|
-
`tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${plan.production ?? "the production branch"}${plan.refs.some((ref) => ref.kind === "branch") ? " or a branch in this push" : ""}; a release tag marks a commit that already landed`,
|
|
1294
|
-
`land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
|
|
1295
|
-
);
|
|
1418
|
+
remoteTip = tip.sha;
|
|
1296
1419
|
}
|
|
1420
|
+
const pushed = plan.refs.find(
|
|
1421
|
+
(ref) => ref.kind === "branch" && ref.name.toLowerCase() === production?.toLowerCase(),
|
|
1422
|
+
);
|
|
1423
|
+
const released =
|
|
1424
|
+
(remoteTip !== null && contains(remoteTip, tag.sha)) ||
|
|
1425
|
+
(pushed !== undefined && contains(pushed.sha, tag.sha));
|
|
1426
|
+
if (!released)
|
|
1427
|
+
return failure(
|
|
1428
|
+
"blocked",
|
|
1429
|
+
`tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${production ?? "the production branch"}${pushed ? ` or the ${production} in this push` : ""}; a release tag marks a commit that already landed on its line's production branch`,
|
|
1430
|
+
`land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
|
|
1431
|
+
);
|
|
1297
1432
|
}
|
|
1298
1433
|
const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
|
|
1299
1434
|
if (pending.length) {
|