omp-conductor 0.15.6 → 0.15.7

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/src/store.ts CHANGED
@@ -864,11 +864,15 @@ export function openStore(dbPath: string): Store {
864
864
 
865
865
  const db = new Database(dbPath, { create: true });
866
866
 
867
+ // Set the wait before *any* pragma that may need SQLite's writer lock.
868
+ // journal_mode itself can return SQLITE_BUSY during another connection's
869
+ // close/checkpoint; setting this afterwards caused load-only CLI flakes
870
+ // before the schema path ever had a chance to use the timeout (#396).
871
+ db.exec("PRAGMA busy_timeout = 5000;");
867
872
  // WAL lets the plugin read status while the daemon is mid-write; the busy
868
873
  // timeout covers the single writer lock they still contend for.
869
874
  db.exec("PRAGMA journal_mode = WAL;");
870
875
  db.exec("PRAGMA foreign_keys = ON;");
871
- db.exec("PRAGMA busy_timeout = 5000;");
872
876
  db.exec(SCHEMA);
873
877
  // #274: a terminally failed digest did not count as sent, but the old unique
874
878
  // index still blocked a replacement under the same daily key. Replace that
package/src/types.ts CHANGED
@@ -125,6 +125,13 @@ export interface RepoTarget {
125
125
  * = no chain check.
126
126
  */
127
127
  migrations?: { dir: string };
128
+ /**
129
+ * Repository-native release preparation. `versionFile` is a repo-relative
130
+ * JSON file with a top-level string `version` (for example
131
+ * `omp/package.json`). A delegated tag is refused until that live value
132
+ * matches the requested tag; `version-bump-pr` is the mediated review path.
133
+ */
134
+ release?: { versionFile: string };
128
135
  }
129
136
 
130
137
  /**
@@ -237,6 +244,7 @@ export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
237
244
  * dead config nor be asked about them by the wizard.
238
245
  */
239
246
  export const RELEASE_SHAPES = [
247
+ "version-bump-pr",
240
248
  "git-tag",
241
249
  "git-push-tags",
242
250
  "package-publish",
@@ -279,6 +287,7 @@ export const LEGACY_RELEASE_POLICIES = ["none", "operator-brief"] as const;
279
287
  * and `undefined !== role` is only accidentally safe.
280
288
  */
281
289
  export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
290
+ "version-bump-pr": "human",
282
291
  "git-tag": "human",
283
292
  "git-push-tags": "human",
284
293
  "package-publish": "human",
@@ -293,6 +302,7 @@ export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
293
302
  * gave — see #122.
294
303
  */
295
304
  export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = {
305
+ "version-bump-pr": "orchestrator",
296
306
  "git-tag": "orchestrator",
297
307
  "git-push-tags": "orchestrator",
298
308
  "package-publish": "orchestrator",
@@ -492,6 +502,21 @@ export const MERGE_REASONS = [
492
502
 
493
503
  export type MergeReason = (typeof MERGE_REASONS)[number];
494
504
 
505
+ /**
506
+ * A one-PR provenance exception an operator placed in config for recovery.
507
+ *
508
+ * It replaces broad standing merge authority for this exact request and lets
509
+ * an orchestrator present an otherwise-unrecorded PR to the ordinary merge
510
+ * gate, including while dispatch is held. It does not relax the normal merge
511
+ * checks. Exact URL, head and reason binding keeps the exception narrower
512
+ * than the action it authorizes (#382).
513
+ */
514
+ export interface RecoveryMergeAuthorization {
515
+ prUrl: string;
516
+ headSha: string;
517
+ reason: Extract<MergeReason, "operator-instructed">;
518
+ }
519
+
495
520
  /** Why a release is being cut now. Closed for the reason {@link MERGE_REASONS} is. */
496
521
  export const RELEASE_REASONS = [
497
522
  /** The batching unit the operator described in POLICY.md has been reached. */
@@ -622,6 +647,13 @@ export interface ProjectConfig {
622
647
  * reaching for `.merge` on a project some test hand-built.
623
648
  */
624
649
  policy?: ProjectPolicy;
650
+ /**
651
+ * Exact operator-authored exceptions for recovery PRs that no run recorded.
652
+ * Optional and hand-edited: setup preserves entries but never invents them.
653
+ * Each entry still passes routing, live-head, checks, migration-chain, and
654
+ * single-flight gates.
655
+ */
656
+ recoveryMerges?: RecoveryMergeAuthorization[];
625
657
  /**
626
658
  * How loud the orchestrator is, and when the daily rollup happens. Optional
627
659
  * on disk — a config written before this key existed loads as
@@ -1836,6 +1868,8 @@ export const VERB_REFUSALS = [
1836
1868
  "open-pr-lookup-error",
1837
1869
  /** The named pull request is not this run's, or not this project's. */
1838
1870
  "pr-not-this-run",
1871
+ /** An unrecorded recovery PR was authorized, but not for these exact inputs. */
1872
+ "recovery-authorization-mismatch",
1839
1873
  /** The run has no pull request to act on. */
1840
1874
  "pr-missing",
1841
1875
  /**
@@ -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 three GitHub-shaped ones, and deliberately not `package-publish` or
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 gh = async (argv: string[], cwd?: string): Promise<ActionOutcome> => {
206
+ const runGh = async (argv: string[], cwd?: string): Promise<{ argv: string[]; result: CommandRun }> => {
109
207
  const full = ["gh", ...argv];
110
- const result = await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) });
111
- return result.ok ? { ok: true, detail: result.stdout.trim() || undefined } : failed(result, full);
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) {
@@ -247,7 +247,7 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
247
247
  mutating: true,
248
248
  allowedRoles: ["orchestrator"],
249
249
  description:
250
- "Cut one release of one declared shape. The caller's role must equal the configured release " +
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 the git-tag and github-release shapes.",
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",