@kungfu-tech/buildchain 3.0.3-alpha.1 → 3.0.3-alpha.2

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.
@@ -0,0 +1,210 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { loadBuildchainConfig } from "./buildchain-config.js";
6
+
7
+ export const PAPER_PATHS = Object.freeze({
8
+ config: ".buildchain/buildchain.toml",
9
+ versionPin: ".buildchain-version",
10
+ contractLock: ".buildchain/contract-lock.json",
11
+ buildWorkflow: ".github/workflows/build.yml",
12
+ releaseWorkflow: ".github/workflows/paper-release.yml",
13
+ reproducibilityReceipt:
14
+ ".buildchain/publication/reproducibility-receipt.json",
15
+ sealedBundle: ".buildchain/admitted/sealed-bundle.json",
16
+ admission: ".buildchain/admitted/publication-admission.json",
17
+ capability: ".buildchain/admitted/publication-capability.json",
18
+ npmBootstrap: ".buildchain/paper/npm-bootstrap.json",
19
+ npmTrust: ".buildchain/paper/npm-trust.json",
20
+ provisioningAuthority: ".buildchain/paper/provisioning-authority.json",
21
+ visibility: ".buildchain/paper/visibility.json",
22
+ });
23
+
24
+ export const PAPER_WORK_BRANCH_PATTERN =
25
+ /^(?:feature|fix|docs|chore|ci|refactor)\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*$/;
26
+
27
+ export function stableJson(value) {
28
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
29
+ if (value && typeof value === "object") {
30
+ return `{${Object.keys(value)
31
+ .sort()
32
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
33
+ .join(",")}}`;
34
+ }
35
+ return JSON.stringify(value);
36
+ }
37
+
38
+ export function sha256Text(value) {
39
+ return `sha256:${crypto.createHash("sha256").update(String(value)).digest("hex")}`;
40
+ }
41
+
42
+ export function readJson(filePath) {
43
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
44
+ return { exists: false, value: undefined, error: "" };
45
+ }
46
+ try {
47
+ return {
48
+ exists: true,
49
+ value: JSON.parse(fs.readFileSync(filePath, "utf8")),
50
+ error: "",
51
+ };
52
+ } catch (error) {
53
+ return { exists: true, value: undefined, error: error.message };
54
+ }
55
+ }
56
+
57
+ export function normalizeRepository(value) {
58
+ const normalized = String(value || "")
59
+ .trim()
60
+ .replace(/^git\+/, "")
61
+ .replace(/^git@github\.com:/, "")
62
+ .replace(/^ssh:\/\/git@github\.com\//, "")
63
+ .replace(/^https?:\/\/github\.com\//, "")
64
+ .replace(/\.git$/, "")
65
+ .replace(/^\/+|\/+$/g, "");
66
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)
67
+ ? normalized
68
+ : "";
69
+ }
70
+
71
+ export function commandResult(
72
+ command,
73
+ args,
74
+ { cwd, env = process.env, timeout = 15000 } = {},
75
+ ) {
76
+ const result = spawnSync(command, args, {
77
+ cwd,
78
+ env,
79
+ encoding: "utf8",
80
+ timeout,
81
+ maxBuffer: 2 * 1024 * 1024,
82
+ });
83
+ return {
84
+ ok: result.status === 0,
85
+ status: result.status ?? 1,
86
+ stdout: String(result.stdout || "").trim(),
87
+ stderr: String(result.stderr || "").trim(),
88
+ error: result.error?.message || "",
89
+ };
90
+ }
91
+
92
+ export function gitResult(cwd, args) {
93
+ return commandResult("git", args, { cwd });
94
+ }
95
+
96
+ export function gitValue(cwd, args) {
97
+ const result = gitResult(cwd, args);
98
+ return result.ok ? result.stdout : "";
99
+ }
100
+
101
+ export function paperConfig(cwd) {
102
+ const loaded = loadBuildchainConfig(cwd);
103
+ if (!loaded)
104
+ return { loaded: undefined, error: `${PAPER_PATHS.config} is missing` };
105
+ if (loaded.config.project?.type !== "publication-artifact") {
106
+ return { loaded, error: 'project.type must be "publication-artifact"' };
107
+ }
108
+ if (!loaded.config.publication)
109
+ return { loaded, error: "[publication] is missing" };
110
+ return { loaded, error: "" };
111
+ }
112
+
113
+ export function parsePaperVersion(version) {
114
+ const normalized = String(version || "")
115
+ .trim()
116
+ .replace(/^v/, "");
117
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
118
+ if (!match)
119
+ throw new Error("publication.version must be semver before planning Alpha");
120
+ return {
121
+ version: normalized,
122
+ major: Number(match[1]),
123
+ minor: Number(match[2]),
124
+ patch: Number(match[3]),
125
+ prerelease: match[4] || "",
126
+ };
127
+ }
128
+
129
+ export function resolvePaperRepository(cwd = process.cwd()) {
130
+ const sourcePackage = readJson(path.resolve(cwd, "package.json")).value;
131
+ const configured =
132
+ typeof sourcePackage?.repository === "string"
133
+ ? sourcePackage.repository
134
+ : sourcePackage?.repository?.url;
135
+ return (
136
+ normalizeRepository(configured) ||
137
+ normalizeRepository(gitValue(cwd, ["config", "--get", "remote.origin.url"]))
138
+ );
139
+ }
140
+
141
+ export function paperDevelopmentRef(cwd) {
142
+ const configResult = paperConfig(cwd);
143
+ if (configResult.error) throw new Error(configResult.error);
144
+ const parsed = parsePaperVersion(
145
+ configResult.loaded.config.publication.version,
146
+ );
147
+ return `dev/v${parsed.major}/v${parsed.major}.${parsed.minor}`;
148
+ }
149
+
150
+ export function remoteBranchObservation(cwd, branch) {
151
+ const result = gitResult(cwd, [
152
+ "ls-remote",
153
+ "--heads",
154
+ "origin",
155
+ `refs/heads/${branch}`,
156
+ ]);
157
+ const sha = result.stdout.split(/\s+/)[0] || "";
158
+ return {
159
+ observed: result.ok,
160
+ ok: result.ok && /^[0-9a-f]{40}$/i.test(sha),
161
+ sha: /^[0-9a-f]{40}$/i.test(sha) ? sha : "",
162
+ error: result.error || result.stderr,
163
+ };
164
+ }
165
+
166
+ export function paperWorkSource(cwd) {
167
+ const repository = resolvePaperRepository(cwd);
168
+ const remotes = gitValue(cwd, ["remote"]).split(/\s+/).filter(Boolean).sort();
169
+ const originUrl = gitValue(cwd, ["config", "--get", "remote.origin.url"]);
170
+ const originRepository = normalizeRepository(originUrl);
171
+ return {
172
+ repository,
173
+ remotes,
174
+ originUrl,
175
+ originRepository,
176
+ canonical:
177
+ remotes.length === 1 &&
178
+ remotes[0] === "origin" &&
179
+ Boolean(repository) &&
180
+ repository === originRepository &&
181
+ repository.startsWith("kungfu-systems/"),
182
+ branch: gitValue(cwd, ["branch", "--show-current"]),
183
+ head: gitValue(cwd, ["rev-parse", "HEAD"]),
184
+ clean: gitResult(cwd, ["status", "--porcelain"]).stdout === "",
185
+ };
186
+ }
187
+
188
+ export function rootedPlan(payload) {
189
+ return { ...payload, planRoot: sha256Text(stableJson(payload)) };
190
+ }
191
+
192
+ export function workCheck(id, ok, message, correctiveCommand = "") {
193
+ return {
194
+ id,
195
+ status: ok ? "pass" : "fail",
196
+ message,
197
+ correctiveCommand: ok ? "" : correctiveCommand,
198
+ };
199
+ }
200
+
201
+ export function normalizedWorkBranch(topic, explicitBranch = "") {
202
+ const candidate = explicitBranch
203
+ ? String(explicitBranch).trim()
204
+ : `feature/${String(topic || "")
205
+ .trim()
206
+ .toLowerCase()
207
+ .replace(/[^a-z0-9._/-]+/g, "-")
208
+ .replace(/^-+|-+$/g, "")}`;
209
+ return PAPER_WORK_BRANCH_PATTERN.test(candidate) ? candidate : "";
210
+ }
@@ -0,0 +1,365 @@
1
+ import path from "node:path";
2
+ import {
3
+ PAPER_WORK_BRANCH_PATTERN,
4
+ gitResult,
5
+ gitValue,
6
+ normalizedWorkBranch,
7
+ paperDevelopmentRef,
8
+ paperWorkSource,
9
+ remoteBranchObservation,
10
+ rootedPlan,
11
+ workCheck,
12
+ } from "./paper-repository.js";
13
+
14
+ export const PAPER_WORK_START_PLAN_CONTRACT =
15
+ "kungfu-buildchain-paper-work-start-plan";
16
+ export const PAPER_WORK_SUBMIT_PLAN_CONTRACT =
17
+ "kungfu-buildchain-paper-work-submit-plan";
18
+
19
+ function failedActions(checks) {
20
+ return checks
21
+ .filter((entry) => entry.status === "fail")
22
+ .map((entry) => ({
23
+ id: `repair-${entry.id}`,
24
+ command: entry.correctiveCommand,
25
+ description: entry.message,
26
+ }));
27
+ }
28
+
29
+ export function createPaperWorkStartPlan({
30
+ cwd = process.cwd(),
31
+ topic = "",
32
+ branch = "",
33
+ } = {}) {
34
+ const resolvedCwd = path.resolve(cwd);
35
+ const source = paperWorkSource(resolvedCwd);
36
+ const targetBranch = normalizedWorkBranch(topic, branch);
37
+ const developmentRef = paperDevelopmentRef(resolvedCwd);
38
+ const remoteDevelopment = remoteBranchObservation(
39
+ resolvedCwd,
40
+ developmentRef,
41
+ );
42
+ const remoteTarget = targetBranch
43
+ ? remoteBranchObservation(resolvedCwd, targetBranch)
44
+ : { observed: false, sha: "" };
45
+ const localTarget = targetBranch
46
+ ? gitValue(resolvedCwd, [
47
+ "rev-parse",
48
+ "--verify",
49
+ `refs/heads/${targetBranch}`,
50
+ ])
51
+ : "";
52
+ const remoteCommitPresent = remoteDevelopment.sha
53
+ ? gitResult(resolvedCwd, [
54
+ "cat-file",
55
+ "-e",
56
+ `${remoteDevelopment.sha}^{commit}`,
57
+ ]).ok
58
+ : false;
59
+ const checks = [
60
+ workCheck(
61
+ "repository.canonical-origin",
62
+ source.canonical,
63
+ "The paper repository has exactly one canonical kungfu-systems origin.",
64
+ "git remote -v",
65
+ ),
66
+ workCheck(
67
+ "source.clean",
68
+ source.clean,
69
+ "The worktree is clean.",
70
+ "git status --short",
71
+ ),
72
+ workCheck(
73
+ "source.development-ref",
74
+ source.branch === developmentRef,
75
+ `The current branch is the configured development ref ${developmentRef}.`,
76
+ `git switch ${developmentRef}`,
77
+ ),
78
+ workCheck(
79
+ "remote.development-ref",
80
+ remoteDevelopment.ok,
81
+ `origin/${developmentRef} resolves to an exact commit.`,
82
+ `git fetch origin ${developmentRef}`,
83
+ ),
84
+ workCheck(
85
+ "source.remote-aligned",
86
+ Boolean(remoteDevelopment.sha) && source.head === remoteDevelopment.sha,
87
+ "HEAD equals the exact remote development commit.",
88
+ `git fetch origin ${developmentRef} && git merge --ff-only origin/${developmentRef}`,
89
+ ),
90
+ workCheck(
91
+ "source.remote-commit-present",
92
+ remoteCommitPresent,
93
+ "The exact remote development commit is present locally.",
94
+ `git fetch origin ${developmentRef}`,
95
+ ),
96
+ workCheck(
97
+ "target.safe-name",
98
+ Boolean(targetBranch),
99
+ "The work branch uses an allowed non-protected prefix and safe slug.",
100
+ "use feature|fix|docs|chore|ci|refactor/<slug>",
101
+ ),
102
+ workCheck(
103
+ "target.absent",
104
+ Boolean(targetBranch) &&
105
+ remoteTarget.observed &&
106
+ !localTarget &&
107
+ !remoteTarget.sha,
108
+ "The work branch does not already exist locally or remotely.",
109
+ "choose a fresh work branch name",
110
+ ),
111
+ ];
112
+ const ok = checks.every((entry) => entry.status === "pass");
113
+ return rootedPlan({
114
+ schemaVersion: 1,
115
+ contract: PAPER_WORK_START_PLAN_CONTRACT,
116
+ ok,
117
+ cwd: resolvedCwd,
118
+ dryRun: true,
119
+ source: {
120
+ ...source,
121
+ developmentRef,
122
+ remoteDevelopmentSha: remoteDevelopment.sha,
123
+ },
124
+ target: { branch: targetBranch, startSha: remoteDevelopment.sha },
125
+ checks,
126
+ mutation: {
127
+ kind: "local-branch-create",
128
+ force: false,
129
+ command: targetBranch
130
+ ? `git switch -c ${targetBranch} ${remoteDevelopment.sha || `<origin/${developmentRef}>`}`
131
+ : "",
132
+ },
133
+ nextActions: ok
134
+ ? [
135
+ {
136
+ id: "create-work-branch",
137
+ command: `buildchain paper work start ${targetBranch} --branch ${targetBranch} --execute --json`,
138
+ description:
139
+ "Create the local work branch from the exact observed remote development commit.",
140
+ },
141
+ ]
142
+ : failedActions(checks),
143
+ });
144
+ }
145
+
146
+ export function executePaperWorkStart(plan) {
147
+ if (!plan || plan.contract !== PAPER_WORK_START_PLAN_CONTRACT || !plan.ok) {
148
+ return {
149
+ ...plan,
150
+ ok: false,
151
+ dryRun: false,
152
+ errorCode: "paper-work-start-blocked",
153
+ };
154
+ }
155
+ const fresh = createPaperWorkStartPlan({
156
+ cwd: plan.cwd,
157
+ branch: plan.target.branch,
158
+ });
159
+ if (!fresh.ok || fresh.planRoot !== plan.planRoot) {
160
+ return {
161
+ ...fresh,
162
+ ok: false,
163
+ dryRun: false,
164
+ errorCode: "paper-work-start-race",
165
+ };
166
+ }
167
+ const switched = gitResult(plan.cwd, [
168
+ "switch",
169
+ "-c",
170
+ plan.target.branch,
171
+ plan.target.startSha,
172
+ ]);
173
+ return {
174
+ ...fresh,
175
+ ok: switched.ok,
176
+ dryRun: false,
177
+ created: switched.ok,
178
+ errorCode: switched.ok ? "" : "paper-work-branch-create-failed",
179
+ stderr: switched.ok ? "" : switched.error || switched.stderr,
180
+ };
181
+ }
182
+
183
+ export function createPaperWorkSubmitPlan({
184
+ cwd = process.cwd(),
185
+ pullRequests = [],
186
+ pullRequestObservation = { ok: true },
187
+ } = {}) {
188
+ const resolvedCwd = path.resolve(cwd);
189
+ const source = paperWorkSource(resolvedCwd);
190
+ const developmentRef = paperDevelopmentRef(resolvedCwd);
191
+ const remoteDevelopment = remoteBranchObservation(
192
+ resolvedCwd,
193
+ developmentRef,
194
+ );
195
+ const remoteWork = source.branch
196
+ ? remoteBranchObservation(resolvedCwd, source.branch)
197
+ : { observed: false, sha: "" };
198
+ const developmentAncestor =
199
+ Boolean(remoteDevelopment.sha) &&
200
+ gitResult(resolvedCwd, [
201
+ "merge-base",
202
+ "--is-ancestor",
203
+ remoteDevelopment.sha,
204
+ source.head,
205
+ ]).ok;
206
+ const remoteWorkAncestor =
207
+ remoteWork.observed &&
208
+ (!remoteWork.sha ||
209
+ gitResult(resolvedCwd, [
210
+ "merge-base",
211
+ "--is-ancestor",
212
+ remoteWork.sha,
213
+ source.head,
214
+ ]).ok);
215
+ const wrongBasePullRequests = pullRequests.filter(
216
+ (entry) =>
217
+ entry.headRefName === source.branch &&
218
+ entry.baseRefName !== developmentRef,
219
+ );
220
+ const matchingPullRequest = pullRequests.find(
221
+ (entry) =>
222
+ entry.headRefName === source.branch &&
223
+ entry.baseRefName === developmentRef,
224
+ );
225
+ const checks = [
226
+ workCheck(
227
+ "repository.canonical-origin",
228
+ source.canonical,
229
+ "The paper repository has exactly one canonical kungfu-systems origin.",
230
+ "git remote -v",
231
+ ),
232
+ workCheck(
233
+ "source.clean",
234
+ source.clean,
235
+ "The worktree is clean.",
236
+ "git status --short",
237
+ ),
238
+ workCheck(
239
+ "source.safe-work-branch",
240
+ PAPER_WORK_BRANCH_PATTERN.test(source.branch),
241
+ "The current branch is an allowed non-protected work branch.",
242
+ "buildchain paper work start <topic>",
243
+ ),
244
+ workCheck(
245
+ "source.committed",
246
+ /^[0-9a-f]{40}$/i.test(source.head),
247
+ "The submitted source resolves to an exact commit.",
248
+ "git status --short",
249
+ ),
250
+ workCheck(
251
+ "remote.development-ref",
252
+ remoteDevelopment.ok,
253
+ `origin/${developmentRef} resolves to an exact commit.`,
254
+ `git fetch origin ${developmentRef}`,
255
+ ),
256
+ workCheck(
257
+ "source.contains-development",
258
+ developmentAncestor,
259
+ "The work branch contains the exact remote development commit.",
260
+ `git fetch origin ${developmentRef} && git rebase origin/${developmentRef}`,
261
+ ),
262
+ workCheck(
263
+ "remote.work-fast-forward",
264
+ remoteWorkAncestor,
265
+ "The remote work branch is absent or can be advanced without force.",
266
+ `git fetch origin ${source.branch}`,
267
+ ),
268
+ workCheck(
269
+ "pull-request.target",
270
+ wrongBasePullRequests.length === 0,
271
+ `No open pull request targets a branch other than ${developmentRef}.`,
272
+ "close or retarget the conflicting pull request",
273
+ ),
274
+ workCheck(
275
+ "pull-request.observed",
276
+ pullRequestObservation.ok === true,
277
+ "Open pull requests for the source branch were observed successfully.",
278
+ "gh auth status",
279
+ ),
280
+ ];
281
+ const ok = checks.every((entry) => entry.status === "pass");
282
+ return rootedPlan({
283
+ schemaVersion: 1,
284
+ contract: PAPER_WORK_SUBMIT_PLAN_CONTRACT,
285
+ ok,
286
+ cwd: resolvedCwd,
287
+ dryRun: true,
288
+ repository: source.repository,
289
+ source: {
290
+ branch: source.branch,
291
+ sha: source.head,
292
+ remoteSha: remoteWork.sha,
293
+ },
294
+ target: { branch: developmentRef, sha: remoteDevelopment.sha },
295
+ pullRequest: matchingPullRequest || null,
296
+ checks,
297
+ mutation: {
298
+ kind: "normal-push-and-pull-request",
299
+ force: false,
300
+ pushCommand: `git push --set-upstream origin HEAD:refs/heads/${source.branch}`,
301
+ pullRequestCommand: matchingPullRequest
302
+ ? ""
303
+ : `gh pr create --repo ${source.repository} --base ${developmentRef} --head ${source.branch}`,
304
+ },
305
+ nextActions: ok
306
+ ? [
307
+ {
308
+ id: matchingPullRequest ? "reuse-pull-request" : "submit-work",
309
+ command: matchingPullRequest
310
+ ? ""
311
+ : "buildchain paper work submit --execute --json",
312
+ description: matchingPullRequest
313
+ ? "Continue the existing correctly targeted pull request."
314
+ : "Push without force and open a pull request to the configured development ref.",
315
+ url: matchingPullRequest?.url || "",
316
+ },
317
+ ]
318
+ : failedActions(checks),
319
+ });
320
+ }
321
+
322
+ export function executePaperWorkSubmitPush(plan) {
323
+ if (!plan || plan.contract !== PAPER_WORK_SUBMIT_PLAN_CONTRACT || !plan.ok) {
324
+ return {
325
+ ...plan,
326
+ ok: false,
327
+ dryRun: false,
328
+ pushed: false,
329
+ errorCode: "paper-work-submit-blocked",
330
+ };
331
+ }
332
+ const currentHead = gitValue(plan.cwd, ["rev-parse", "HEAD"]);
333
+ const currentBranch = gitValue(plan.cwd, ["branch", "--show-current"]);
334
+ const currentClean =
335
+ gitResult(plan.cwd, ["status", "--porcelain"]).stdout === "";
336
+ const target = remoteBranchObservation(plan.cwd, plan.target.branch);
337
+ if (
338
+ currentHead !== plan.source.sha ||
339
+ currentBranch !== plan.source.branch ||
340
+ !currentClean ||
341
+ target.sha !== plan.target.sha
342
+ ) {
343
+ return {
344
+ ...plan,
345
+ ok: false,
346
+ dryRun: false,
347
+ pushed: false,
348
+ errorCode: "paper-work-submit-race",
349
+ };
350
+ }
351
+ const pushed = gitResult(plan.cwd, [
352
+ "push",
353
+ "--set-upstream",
354
+ "origin",
355
+ `HEAD:refs/heads/${plan.source.branch}`,
356
+ ]);
357
+ return {
358
+ ...plan,
359
+ ok: pushed.ok,
360
+ dryRun: false,
361
+ pushed: pushed.ok,
362
+ errorCode: pushed.ok ? "" : "paper-work-push-failed",
363
+ stderr: pushed.ok ? "" : pushed.error || pushed.stderr,
364
+ };
365
+ }