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.
@@ -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",