omp-conductor 0.15.6 → 0.15.8
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/README.md +103 -27
- package/package.json +1 -1
- package/schema/config.schema.json +39 -0
- package/src/briefs/orchestrator.md +34 -10
- package/src/briefs/policy.md +8 -3
- package/src/cli.ts +62 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/escalate.ts +69 -1
- package/src/fleet.ts +19 -39
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +49 -2
- package/src/setup-discover.ts +425 -0
- package/src/setup-wizard.ts +108 -34
- package/src/setup.ts +25 -1
- package/src/store.ts +5 -1
- package/src/types.ts +34 -0
- package/src/verbs/actions.ts +407 -4
- package/src/verbs/protocol.ts +2 -2
- package/src/verbs/server.ts +141 -27
package/src/verbs/actions.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
|
22
22
|
/**
|
|
23
23
|
* What this daemon can actually cut.
|
|
24
24
|
*
|
|
25
|
-
* The
|
|
25
|
+
* The four GitHub-shaped ones, and deliberately not `package-publish` or
|
|
26
26
|
* `deploy`: the daemon holds a GitHub credential and nothing else — no npm
|
|
27
27
|
* token, no registry login, no deploy key — and that is deliberate. A shape
|
|
28
28
|
* outside this list is refused by name in `server.ts` rather than
|
|
@@ -31,6 +31,7 @@ import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
|
31
31
|
* of them tells an operator to go and do it themselves.
|
|
32
32
|
*/
|
|
33
33
|
export const GITHUB_RELEASABLE_SHAPES: readonly ReleaseShape[] = [
|
|
34
|
+
"version-bump-pr",
|
|
34
35
|
"git-tag",
|
|
35
36
|
"git-push-tags",
|
|
36
37
|
"github-release",
|
|
@@ -51,6 +52,103 @@ export type CommandRunner = (
|
|
|
51
52
|
export type MirrorPreparer = (repo: RepoTarget, mirrorRoot: string) => Promise<string>;
|
|
52
53
|
|
|
53
54
|
type CommitOutcome = { ok: true; sha: string } | { ok: false; stderr: string };
|
|
55
|
+
type ReadOutcome<T> = { ok: true; value: T } | { ok: false; stderr: string };
|
|
56
|
+
|
|
57
|
+
interface VersionPullRequest {
|
|
58
|
+
url: string;
|
|
59
|
+
headRefOid: string;
|
|
60
|
+
baseRefOid: string;
|
|
61
|
+
headRefName: string;
|
|
62
|
+
baseRefName: string;
|
|
63
|
+
files: { path: string }[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
67
|
+
const RELEASE_VERSION_TAG = new RegExp(`^v(${SEMVER.source.slice(1, -1)})$`);
|
|
68
|
+
|
|
69
|
+
function versionFromTag(tag: string): string | undefined {
|
|
70
|
+
return RELEASE_VERSION_TAG.exec(tag)?.[1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compareSemver(left: string, right: string): number | undefined {
|
|
74
|
+
const l = SEMVER.exec(left);
|
|
75
|
+
const r = SEMVER.exec(right);
|
|
76
|
+
if (l === null || r === null) return undefined;
|
|
77
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
78
|
+
const a = BigInt(l[index] as string);
|
|
79
|
+
const b = BigInt(r[index] as string);
|
|
80
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
const leftPre = l[4]?.split(".");
|
|
83
|
+
const rightPre = r[4]?.split(".");
|
|
84
|
+
if (leftPre === undefined || rightPre === undefined) {
|
|
85
|
+
return leftPre === rightPre ? 0 : leftPre === undefined ? 1 : -1;
|
|
86
|
+
}
|
|
87
|
+
const count = Math.max(leftPre.length, rightPre.length);
|
|
88
|
+
for (let index = 0; index < count; index += 1) {
|
|
89
|
+
const a = leftPre[index];
|
|
90
|
+
const b = rightPre[index];
|
|
91
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
|
|
92
|
+
if (a === b) continue;
|
|
93
|
+
const aNumeric = /^\d+$/.test(a);
|
|
94
|
+
const bNumeric = /^\d+$/.test(b);
|
|
95
|
+
if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1;
|
|
96
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
|
|
97
|
+
return a < b ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function objectFromJson(raw: string): Record<string, unknown> | undefined {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(raw);
|
|
105
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
106
|
+
? (parsed as Record<string, unknown>)
|
|
107
|
+
: undefined;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function exactVersionChange(
|
|
114
|
+
base: Record<string, unknown>,
|
|
115
|
+
head: Record<string, unknown>,
|
|
116
|
+
version: string,
|
|
117
|
+
): boolean {
|
|
118
|
+
if (typeof base.version !== "string") return false;
|
|
119
|
+
return JSON.stringify({ ...base, version }) === JSON.stringify(head);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function versionPullRequest(raw: unknown): VersionPullRequest | undefined {
|
|
123
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
124
|
+
const pr = raw as Record<string, unknown>;
|
|
125
|
+
const files = pr.files;
|
|
126
|
+
if (
|
|
127
|
+
typeof pr.url !== "string" ||
|
|
128
|
+
!FULL_COMMIT.test(String(pr.headRefOid)) ||
|
|
129
|
+
!FULL_COMMIT.test(String(pr.baseRefOid)) ||
|
|
130
|
+
typeof pr.headRefName !== "string" ||
|
|
131
|
+
typeof pr.baseRefName !== "string" ||
|
|
132
|
+
!Array.isArray(files) ||
|
|
133
|
+
files.some(
|
|
134
|
+
(file) =>
|
|
135
|
+
typeof file !== "object" ||
|
|
136
|
+
file === null ||
|
|
137
|
+
Array.isArray(file) ||
|
|
138
|
+
typeof (file as Record<string, unknown>).path !== "string",
|
|
139
|
+
)
|
|
140
|
+
) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
url: pr.url,
|
|
145
|
+
headRefOid: String(pr.headRefOid).toLowerCase(),
|
|
146
|
+
baseRefOid: String(pr.baseRefOid).toLowerCase(),
|
|
147
|
+
headRefName: pr.headRefName,
|
|
148
|
+
baseRefName: pr.baseRefName,
|
|
149
|
+
files: files.map((file) => ({ path: String((file as Record<string, unknown>).path) })),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
54
152
|
|
|
55
153
|
const FULL_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
56
154
|
const RELEASE_TAG_CONFIG = [
|
|
@@ -105,10 +203,49 @@ export function githubVerbActions(
|
|
|
105
203
|
): VerbActions {
|
|
106
204
|
const env = (): Record<string, string> => credentialedEnv();
|
|
107
205
|
|
|
108
|
-
const
|
|
206
|
+
const runGh = async (argv: string[], cwd?: string): Promise<{ argv: string[]; result: CommandRun }> => {
|
|
109
207
|
const full = ["gh", ...argv];
|
|
110
|
-
|
|
111
|
-
|
|
208
|
+
return {
|
|
209
|
+
argv: full,
|
|
210
|
+
result: await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) }),
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
|
|
215
|
+
const result = await runGh(argv, cwd);
|
|
216
|
+
return result.result.ok
|
|
217
|
+
? { ok: true, detail: result.result.stdout.trim() || undefined }
|
|
218
|
+
: failed(result.result, result.argv);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const ghJson = async <T>(argv: string[], fact: string): Promise<ReadOutcome<T>> => {
|
|
222
|
+
const result = await runGh(argv);
|
|
223
|
+
if (!result.result.ok) return failed(result.result, result.argv);
|
|
224
|
+
try {
|
|
225
|
+
return { ok: true, value: JSON.parse(result.result.stdout) as T };
|
|
226
|
+
} catch {
|
|
227
|
+
return { ok: false, stderr: `${fact} returned invalid JSON` };
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const readVersionDocument = async (
|
|
232
|
+
slug: string,
|
|
233
|
+
path: string,
|
|
234
|
+
ref: string,
|
|
235
|
+
): Promise<ReadOutcome<{ sha: string; document: Record<string, unknown> }>> => {
|
|
236
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
237
|
+
const file = await ghJson<{ sha?: unknown; content?: unknown }>(
|
|
238
|
+
["api", `repos/${slug}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`],
|
|
239
|
+
`${path} at ${ref}`,
|
|
240
|
+
);
|
|
241
|
+
if (!file.ok) return file;
|
|
242
|
+
if (typeof file.value.sha !== "string" || typeof file.value.content !== "string") {
|
|
243
|
+
return { ok: false, stderr: `${path} at ${ref} returned no file SHA or content` };
|
|
244
|
+
}
|
|
245
|
+
const document = objectFromJson(Buffer.from(file.value.content.replace(/\s/g, ""), "base64").toString("utf8"));
|
|
246
|
+
return document === undefined
|
|
247
|
+
? { ok: false, stderr: `${path} at ${ref} is not a JSON object` }
|
|
248
|
+
: { ok: true, value: { sha: file.value.sha, document } };
|
|
112
249
|
};
|
|
113
250
|
|
|
114
251
|
const git = async (mirror: string, args: string[]): Promise<{ argv: string[]; result: CommandRun }> => {
|
|
@@ -174,6 +311,253 @@ export function githubVerbActions(
|
|
|
174
311
|
}
|
|
175
312
|
};
|
|
176
313
|
|
|
314
|
+
const releaseVersionRefusal = async (
|
|
315
|
+
mirror: string,
|
|
316
|
+
repo: RepoTarget,
|
|
317
|
+
ref: string,
|
|
318
|
+
tag: string,
|
|
319
|
+
): Promise<{ ok: true } | { ok: false; stderr: string }> => {
|
|
320
|
+
const versionFile = repo.release?.versionFile;
|
|
321
|
+
if (versionFile === undefined) return { ok: true };
|
|
322
|
+
const version = versionFromTag(tag);
|
|
323
|
+
if (version === undefined) {
|
|
324
|
+
return { ok: false, stderr: `${repo.name} requires a v<semver> tag because it declares ${versionFile}` };
|
|
325
|
+
}
|
|
326
|
+
const shown = await git(mirror, ["show", `${ref}:${versionFile}`]);
|
|
327
|
+
if (!shown.result.ok) return failed(shown.result, shown.argv);
|
|
328
|
+
const document = objectFromJson(shown.result.stdout);
|
|
329
|
+
if (document === undefined || typeof document.version !== "string") {
|
|
330
|
+
return { ok: false, stderr: `${versionFile} at ${ref} has no top-level string version` };
|
|
331
|
+
}
|
|
332
|
+
return document.version === version
|
|
333
|
+
? { ok: true }
|
|
334
|
+
: {
|
|
335
|
+
ok: false,
|
|
336
|
+
stderr:
|
|
337
|
+
`refusing ${tag}: live ${versionFile} declares ${document.version}, not ${version}. ` +
|
|
338
|
+
"Land the mediated version-bump-pr first.",
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const validateVersionPr = async (
|
|
343
|
+
repo: RepoTarget,
|
|
344
|
+
version: string,
|
|
345
|
+
branch: string,
|
|
346
|
+
raw: unknown,
|
|
347
|
+
): Promise<ReadOutcome<VersionPullRequest>> => {
|
|
348
|
+
const pr = versionPullRequest(raw);
|
|
349
|
+
const versionFile = repo.release?.versionFile;
|
|
350
|
+
if (pr === undefined || versionFile === undefined) {
|
|
351
|
+
return { ok: false, stderr: "release preparation lookup returned an invalid pull request" };
|
|
352
|
+
}
|
|
353
|
+
if (
|
|
354
|
+
pr.headRefName !== branch ||
|
|
355
|
+
pr.baseRefName !== repo.defaultBranch ||
|
|
356
|
+
pr.files.length !== 1 ||
|
|
357
|
+
pr.files[0]?.path !== versionFile
|
|
358
|
+
) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
stderr:
|
|
362
|
+
`refusing release preparation ${pr.url}: expected ${branch} -> ${repo.defaultBranch} ` +
|
|
363
|
+
`with only ${versionFile} changed`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const slug = repoSlugFor(repo);
|
|
367
|
+
const [base, head] = await Promise.all([
|
|
368
|
+
readVersionDocument(slug, versionFile, pr.baseRefOid),
|
|
369
|
+
readVersionDocument(slug, versionFile, pr.headRefOid),
|
|
370
|
+
]);
|
|
371
|
+
if (!base.ok) return base;
|
|
372
|
+
if (!head.ok) return head;
|
|
373
|
+
if (!exactVersionChange(base.value.document, head.value.document, version)) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
stderr:
|
|
377
|
+
`refusing release preparation ${pr.url}: ${versionFile} is not an exact version-only change to ${version}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { ok: true, value: pr };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const prepareVersionBump = async (execution: ReleaseExecution, tag: string): Promise<ActionOutcome> => {
|
|
384
|
+
const versionFile = execution.repo.release?.versionFile;
|
|
385
|
+
if (versionFile === undefined) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
stderr: `${execution.repo.name} declares no release.versionFile, so no version-bump-pr can be prepared`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const version = versionFromTag(tag);
|
|
392
|
+
if (version === undefined) {
|
|
393
|
+
return { ok: false, stderr: `version-bump-pr needs a v<semver> tag, found ${JSON.stringify(tag)}` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const repo = execution.repo;
|
|
397
|
+
const slug = repoSlugFor(repo);
|
|
398
|
+
const branch = `conductor/release-${version}`;
|
|
399
|
+
const refPath = `repos/${slug}/git/ref/heads/${encodeURIComponent(branch)}`;
|
|
400
|
+
const live = await ghJson<{ object?: { sha?: unknown } }>(
|
|
401
|
+
["api", `repos/${slug}/git/ref/heads/${encodeURIComponent(repo.defaultBranch)}`],
|
|
402
|
+
`live ${repo.defaultBranch}`,
|
|
403
|
+
);
|
|
404
|
+
const liveSha = live.ok ? commitFrom(String(live.value.object?.sha ?? "")) : undefined;
|
|
405
|
+
if (!live.ok) return live;
|
|
406
|
+
if (liveSha === undefined) {
|
|
407
|
+
return { ok: false, stderr: `live ${repo.defaultBranch} returned no full commit SHA` };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const liveFile = await readVersionDocument(slug, versionFile, liveSha);
|
|
411
|
+
if (!liveFile.ok) return liveFile;
|
|
412
|
+
if (liveFile.value.document.version === version) {
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
sha: liveSha,
|
|
416
|
+
detail: `${versionFile} already declares ${version} on ${repo.defaultBranch}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (typeof liveFile.value.document.version !== "string") {
|
|
420
|
+
return { ok: false, stderr: `${versionFile} has no top-level string version` };
|
|
421
|
+
}
|
|
422
|
+
const order = compareSemver(version, liveFile.value.document.version);
|
|
423
|
+
if (order === undefined) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
stderr: `${versionFile} declares non-semantic version ${JSON.stringify(liveFile.value.document.version)}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (order <= 0) {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
stderr:
|
|
433
|
+
`refusing release preparation ${version}: it is not newer than live ` +
|
|
434
|
+
`${versionFile} version ${liveFile.value.document.version}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const listed = await ghJson<unknown[]>(
|
|
439
|
+
[
|
|
440
|
+
"pr",
|
|
441
|
+
"list",
|
|
442
|
+
"--repo",
|
|
443
|
+
slug,
|
|
444
|
+
"--head",
|
|
445
|
+
branch,
|
|
446
|
+
"--state",
|
|
447
|
+
"open",
|
|
448
|
+
"--limit",
|
|
449
|
+
"2",
|
|
450
|
+
"--json",
|
|
451
|
+
"url,headRefOid,baseRefOid,headRefName,baseRefName,files",
|
|
452
|
+
],
|
|
453
|
+
`open release preparation for ${branch}`,
|
|
454
|
+
);
|
|
455
|
+
if (!listed.ok) return listed;
|
|
456
|
+
if (!Array.isArray(listed.value) || listed.value.length > 1) {
|
|
457
|
+
return { ok: false, stderr: `release preparation lookup for ${branch} did not return one unambiguous list` };
|
|
458
|
+
}
|
|
459
|
+
const existing = listed.value[0];
|
|
460
|
+
if (existing !== undefined) {
|
|
461
|
+
const validated = await validateVersionPr(repo, version, branch, existing);
|
|
462
|
+
return validated.ok
|
|
463
|
+
? {
|
|
464
|
+
ok: true,
|
|
465
|
+
sha: validated.value.headRefOid,
|
|
466
|
+
detail: `reused exact version-only pull request ${validated.value.url}`,
|
|
467
|
+
review: { prUrl: validated.value.url, created: false },
|
|
468
|
+
}
|
|
469
|
+
: validated;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const branchRef = await runGh(["api", refPath, "--jq", ".object.sha"]);
|
|
473
|
+
let branchSha: string | undefined;
|
|
474
|
+
if (branchRef.result.ok) {
|
|
475
|
+
branchSha = commitFrom(branchRef.result.stdout);
|
|
476
|
+
if (branchSha === undefined) {
|
|
477
|
+
return { ok: false, stderr: `${branch} returned no full commit SHA` };
|
|
478
|
+
}
|
|
479
|
+
} else if (!/\b404\b|not found/i.test(branchRef.result.stderr)) {
|
|
480
|
+
return failed(branchRef.result, branchRef.argv);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (branchSha === undefined) {
|
|
484
|
+
const created = await gh([
|
|
485
|
+
"api",
|
|
486
|
+
"--method",
|
|
487
|
+
"POST",
|
|
488
|
+
`repos/${slug}/git/refs`,
|
|
489
|
+
"-f",
|
|
490
|
+
`ref=refs/heads/${branch}`,
|
|
491
|
+
"-f",
|
|
492
|
+
`sha=${liveSha}`,
|
|
493
|
+
]);
|
|
494
|
+
if (!created.ok) return created;
|
|
495
|
+
branchSha = liveSha;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const branchFile = await readVersionDocument(slug, versionFile, branchSha);
|
|
499
|
+
if (!branchFile.ok) return branchFile;
|
|
500
|
+
if (branchFile.value.document.version !== version) {
|
|
501
|
+
if (branchSha !== liveSha || JSON.stringify(branchFile.value.document) !== JSON.stringify(liveFile.value.document)) {
|
|
502
|
+
return {
|
|
503
|
+
ok: false,
|
|
504
|
+
stderr: `refusing to reuse ${branch}: it is not the live base and not an exact ${version} version bump`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const content = Buffer.from(
|
|
508
|
+
`${JSON.stringify({ ...liveFile.value.document, version }, null, 2)}\n`,
|
|
509
|
+
"utf8",
|
|
510
|
+
).toString("base64");
|
|
511
|
+
const updated = await gh([
|
|
512
|
+
"api",
|
|
513
|
+
"--method",
|
|
514
|
+
"PUT",
|
|
515
|
+
`repos/${slug}/contents/${versionFile.split("/").map(encodeURIComponent).join("/")}`,
|
|
516
|
+
"-f",
|
|
517
|
+
`message=chore: release ${repo.name} ${version}`,
|
|
518
|
+
"-f",
|
|
519
|
+
`content=${content}`,
|
|
520
|
+
"-f",
|
|
521
|
+
`sha=${branchFile.value.sha}`,
|
|
522
|
+
"-f",
|
|
523
|
+
`branch=${branch}`,
|
|
524
|
+
]);
|
|
525
|
+
if (!updated.ok) return updated;
|
|
526
|
+
} else if (!exactVersionChange(liveFile.value.document, branchFile.value.document, version)) {
|
|
527
|
+
return { ok: false, stderr: `refusing to reuse ${branch}: ${versionFile} contains changes besides version ${version}` };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const opened = await runGh([
|
|
531
|
+
"pr",
|
|
532
|
+
"create",
|
|
533
|
+
"--repo",
|
|
534
|
+
slug,
|
|
535
|
+
"--head",
|
|
536
|
+
branch,
|
|
537
|
+
"--base",
|
|
538
|
+
repo.defaultBranch,
|
|
539
|
+
"--title",
|
|
540
|
+
`chore: release ${repo.name} ${version}`,
|
|
541
|
+
"--body",
|
|
542
|
+
`Prepare ${tag}. This PR changes only ${versionFile}; release remains blocked until it lands.`,
|
|
543
|
+
]);
|
|
544
|
+
if (!opened.result.ok) return failed(opened.result, opened.argv);
|
|
545
|
+
const prUrl = opened.result.stdout.trim();
|
|
546
|
+
if (!/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/.test(prUrl)) {
|
|
547
|
+
return { ok: false, stderr: `gh pr create returned no pull request URL` };
|
|
548
|
+
}
|
|
549
|
+
const head = await ghJson<{ object?: { sha?: unknown } }>(["api", refPath], branch);
|
|
550
|
+
const headSha = head.ok ? commitFrom(String(head.value.object?.sha ?? "")) : undefined;
|
|
551
|
+
if (!head.ok) return head;
|
|
552
|
+
if (headSha === undefined) return { ok: false, stderr: `${branch} returned no full commit SHA after PR creation` };
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
sha: headSha,
|
|
556
|
+
detail: `opened exact version-only pull request ${prUrl}`,
|
|
557
|
+
review: { prUrl, created: true },
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
|
|
177
561
|
return {
|
|
178
562
|
releasableShapes: GITHUB_RELEASABLE_SHAPES,
|
|
179
563
|
|
|
@@ -220,7 +604,22 @@ export function githubVerbActions(
|
|
|
220
604
|
if (tag === undefined) {
|
|
221
605
|
return { ok: false, stderr: `a ${execution.shape} needs a tag; none was given` };
|
|
222
606
|
}
|
|
607
|
+
if (execution.shape === "version-bump-pr") {
|
|
608
|
+
return prepareVersionBump(execution, tag);
|
|
609
|
+
}
|
|
223
610
|
if (execution.shape === "github-release") {
|
|
611
|
+
if (execution.repo.release === undefined) {
|
|
612
|
+
return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
|
|
613
|
+
}
|
|
614
|
+
const prepared = await releaseMirror(execution.repo);
|
|
615
|
+
if (!prepared.ok) return prepared;
|
|
616
|
+
const remoteTag = await remoteTagCommit(prepared.path, tag);
|
|
617
|
+
if (!remoteTag.ok) return remoteTag;
|
|
618
|
+
if (remoteTag.sha === undefined) {
|
|
619
|
+
return { ok: false, stderr: `refusing GitHub release ${tag}: push the reviewed tag first` };
|
|
620
|
+
}
|
|
621
|
+
const versionReady = await releaseVersionRefusal(prepared.path, execution.repo, remoteTag.sha, tag);
|
|
622
|
+
if (!versionReady.ok) return versionReady;
|
|
224
623
|
return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]);
|
|
225
624
|
}
|
|
226
625
|
// The two git shapes act on the project's serialized, freshly fetched
|
|
@@ -241,6 +640,8 @@ export function githubVerbActions(
|
|
|
241
640
|
if (!liveBefore.ok) return liveBefore;
|
|
242
641
|
if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
|
|
243
642
|
|
|
643
|
+
const versionReady = await releaseVersionRefusal(mirror, execution.repo, target.sha, tag);
|
|
644
|
+
if (!versionReady.ok) return versionReady;
|
|
244
645
|
const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]);
|
|
245
646
|
let previousSha: string | undefined;
|
|
246
647
|
if (localTag.result.ok) {
|
|
@@ -338,6 +739,8 @@ export function githubVerbActions(
|
|
|
338
739
|
"A published tag is never force-moved; cut a new tag instead.",
|
|
339
740
|
};
|
|
340
741
|
}
|
|
742
|
+
const versionReady = await releaseVersionRefusal(mirror, execution.repo, live.sha, tag);
|
|
743
|
+
if (!versionReady.ok) return versionReady;
|
|
341
744
|
|
|
342
745
|
let oldSha: string | undefined;
|
|
343
746
|
if (tagSha !== live.sha) {
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -247,7 +247,7 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
247
247
|
mutating: true,
|
|
248
248
|
allowedRoles: ["orchestrator"],
|
|
249
249
|
description:
|
|
250
|
-
"
|
|
250
|
+
"Perform one mediated release step. The caller's role must equal the configured release " +
|
|
251
251
|
"holder and the per-shape grant must permit it; a worker is refused whatever the config says.",
|
|
252
252
|
args: {
|
|
253
253
|
shape: {
|
|
@@ -270,7 +270,7 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
270
270
|
tag: {
|
|
271
271
|
type: "string",
|
|
272
272
|
required: false,
|
|
273
|
-
description: "Tag to cut, for
|
|
273
|
+
description: "Tag to prepare or cut, for version-bump-pr, git-tag, and github-release.",
|
|
274
274
|
},
|
|
275
275
|
artefact: {
|
|
276
276
|
type: "string",
|