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/README.md +99 -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 +61 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/fleet.ts +14 -35
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +47 -0
- package/src/setup-discover.ts +425 -0
- package/src/setup-wizard.ts +106 -10
- 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/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
|
/**
|
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",
|