@kungfu-tech/buildchain 2.9.0 → 2.9.1

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,298 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const BUILDCHAIN_PUBLIC_SURFACE_AUDIT_CONTRACT = "kungfu-buildchain-public-surface-reverse-audit";
6
+
7
+ function readText(root, relPath) {
8
+ return fs.readFileSync(path.join(root, relPath), "utf8");
9
+ }
10
+
11
+ function readJson(root, relPath, fallback = undefined) {
12
+ const filePath = path.join(root, relPath);
13
+ if (!fs.existsSync(filePath)) return fallback;
14
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
15
+ }
16
+
17
+ function sha256(value) {
18
+ return crypto.createHash("sha256").update(value).digest("hex");
19
+ }
20
+
21
+ function uniqueSorted(values) {
22
+ return [...new Set(values.filter(Boolean))].sort();
23
+ }
24
+
25
+ function listFiles(root, dir, predicate = () => true) {
26
+ const base = path.join(root, dir);
27
+ if (!fs.existsSync(base)) return [];
28
+ return fs.readdirSync(base, { withFileTypes: true })
29
+ .filter((entry) => entry.isFile() && predicate(entry.name))
30
+ .map((entry) => `${dir}/${entry.name}`)
31
+ .sort();
32
+ }
33
+
34
+ function listDirectories(root, dir) {
35
+ const base = path.join(root, dir);
36
+ if (!fs.existsSync(base)) return [];
37
+ return fs.readdirSync(base, { withFileTypes: true })
38
+ .filter((entry) => entry.isDirectory())
39
+ .map((entry) => `${dir}/${entry.name}`)
40
+ .sort();
41
+ }
42
+
43
+ function commandId(first = "", second = "") {
44
+ const head = String(first || "").trim();
45
+ const sub = String(second || "").trim();
46
+ if (!head) return "";
47
+ if (["-h", "--help", "help"].includes(head)) return "help";
48
+ if (["-v", "--version", "version"].includes(head)) return "version";
49
+ if (head === "release" && ["--dry-run", "dry-run", "explain"].includes(sub)) return "release-dry-run";
50
+ if (head === "release" && sub === "line") return "release-line-open";
51
+ if (head === "release") return "release-transaction";
52
+ if (head === "transaction") return "transaction-inspect";
53
+ if (head === "collect" && sub) return `collect-${sub}`;
54
+ if (head === "verify" && sub) return `verify-${sub}`;
55
+ if (head === "explain" && sub) return `explain-${sub}`;
56
+ if (head === "inspect" && sub) return `inspect-${sub}`;
57
+ if (head === "npm" && sub) return `npm-${sub}`;
58
+ if (head === "lifecycle" && sub) return "lifecycle";
59
+ if (head === "log" && sub) return "logging";
60
+ if (head === "diagnostics" && sub) return `diagnostics-${sub}`;
61
+ if (head === "facts" && sub) return "build-facts";
62
+ if (head === "sample" && sub) return `sample-${sub}`;
63
+ if (head === "badges" && sub) return `badges-${sub}`;
64
+ if (head === "homebrew" && sub) return `homebrew-${sub}`;
65
+ if (head === "release-propagation") return "release-propagation";
66
+ if (head === "publish-source") return "publish-source";
67
+ if (head === "build-contract") return "build-contract";
68
+ if (head === "infra-contract") return "infra-contract";
69
+ if (head === "web-surface") return "web-surface";
70
+ return head;
71
+ }
72
+
73
+ export function enumerateCliCommandsFromBin({ root = process.cwd(), binPath = "bin/buildchain.mjs" } = {}) {
74
+ const source = readText(root, binPath);
75
+ const usageMatch = source.match(/return `Usage:\n([\s\S]*?)`;\n}/);
76
+ const usage = usageMatch?.[1] || "";
77
+ const usageCommands = [];
78
+ for (const line of usage.split(/\r?\n/)) {
79
+ const match = line.trim().match(/^buildchain\s+([^\s]+)(?:\s+([^\s]+))?/);
80
+ if (!match) continue;
81
+ usageCommands.push({
82
+ id: commandId(match[1], match[2]),
83
+ usage: line.trim().replace(/\s+/g, " "),
84
+ });
85
+ }
86
+ const dispatchCommands = [...source.matchAll(/if\s*\(\s*command\s*===\s*"([^"]+)"/g)]
87
+ .map((match) => commandId(match[1]));
88
+ return uniqueSorted([
89
+ ...usageCommands.map((entry) => entry.id),
90
+ ...dispatchCommands,
91
+ ]).map((id) => ({
92
+ id,
93
+ source: "bin/buildchain.mjs",
94
+ usage: usageCommands.find((entry) => entry.id === id)?.usage || `buildchain ${id}`,
95
+ }));
96
+ }
97
+
98
+ function parseYamlTopLevelInputs(text) {
99
+ const lines = text.split(/\r?\n/);
100
+ const inputs = [];
101
+ let inInputs = false;
102
+ let indent = 0;
103
+ for (const line of lines) {
104
+ const match = line.match(/^(\s*)inputs:\s*$/);
105
+ if (match) {
106
+ inInputs = true;
107
+ indent = match[1].length;
108
+ continue;
109
+ }
110
+ if (!inInputs) continue;
111
+ const currentIndent = line.match(/^(\s*)/)?.[1].length || 0;
112
+ if (line.trim() && currentIndent <= indent) {
113
+ inInputs = false;
114
+ continue;
115
+ }
116
+ const inputMatch = line.match(new RegExp(`^\\s{${indent + 2}}([A-Za-z0-9_-]+):\\s*$`));
117
+ if (inputMatch) inputs.push(inputMatch[1]);
118
+ }
119
+ return uniqueSorted(inputs);
120
+ }
121
+
122
+ export function enumerateWorkflowInputs({ root = process.cwd() } = {}) {
123
+ return listFiles(root, ".github/workflows", (name) => /\.ya?ml$/.test(name)).map((relPath) => {
124
+ const inputs = parseYamlTopLevelInputs(readText(root, relPath));
125
+ return {
126
+ id: relPath.replace(/^\.github\/workflows\//, "").replace(/\.ya?ml$/, ""),
127
+ path: relPath,
128
+ inputs,
129
+ inputCount: inputs.length,
130
+ };
131
+ });
132
+ }
133
+
134
+ export function enumerateActionInputs({ root = process.cwd() } = {}) {
135
+ return listDirectories(root, "actions").map((dir) => {
136
+ const relPath = `${dir}/action.yml`;
137
+ const inputs = fs.existsSync(path.join(root, relPath))
138
+ ? parseYamlTopLevelInputs(readText(root, relPath))
139
+ : [];
140
+ return {
141
+ id: dir.replace(/^actions\//, ""),
142
+ path: relPath,
143
+ inputs,
144
+ inputCount: inputs.length,
145
+ };
146
+ });
147
+ }
148
+
149
+ export function enumerateSitePages({ root = process.cwd() } = {}) {
150
+ const pageRegistry = readJson(root, "dist/site/page-registry.json", {});
151
+ return (Array.isArray(pageRegistry?.pages) ? pageRegistry.pages : []).map((page) => ({
152
+ id: page.id || page.path,
153
+ path: page.path,
154
+ category: page.category || "",
155
+ })).sort((a, b) => String(a.id).localeCompare(String(b.id)));
156
+ }
157
+
158
+ export function enumerateDocCommandRefs({ root = process.cwd() } = {}) {
159
+ const knownCommandIds = new Set(enumerateCliCommandsFromBin({ root }).map((entry) => entry.id));
160
+ const docs = [
161
+ "README.md",
162
+ ...listFiles(root, "docs", (name) => name.endsWith(".md")),
163
+ ...listDirectories(root, "actions").map((dir) => `${dir}/README.md`).filter((relPath) => fs.existsSync(path.join(root, relPath))),
164
+ ];
165
+ const refs = [];
166
+ for (const relPath of docs) {
167
+ const text = readText(root, relPath);
168
+ const codeSegments = [];
169
+ for (const match of text.matchAll(/```[\w-]*\n([\s\S]*?)```/g)) {
170
+ codeSegments.push({ kind: "block", text: match[1] });
171
+ }
172
+ for (const match of text.matchAll(/`([^`\n]*\bbuildchain\b[^`\n]*)`/g)) {
173
+ codeSegments.push({ kind: "inline", text: match[1] });
174
+ }
175
+ for (const segment of codeSegments) {
176
+ const commandText = segment.kind === "block"
177
+ ? segment.text.split(/\r?\n/)
178
+ .map((line) => line.trim())
179
+ .filter((line) => /^(?:[$>]\s*)?(?:npx\s+(?:@kungfu-tech\/buildchain|buildchain)\s+|node\s+bin\/buildchain\.mjs\s+|buildchain\s+)/.test(line))
180
+ .join("\n")
181
+ : segment.text;
182
+ for (const match of commandText.matchAll(/(?:^|[\s$>])(?:npx\s+(?:@kungfu-tech\/buildchain|buildchain)\s+|node\s+bin\/buildchain\.mjs\s+|buildchain\s+)([a-z0-9][a-z0-9-]*|--help|--version|-h|-v)(?:\s+([a-z0-9][a-z0-9-]*|--[a-z0-9-]+))?/gm)) {
183
+ const id = commandId(match[1], match[2]);
184
+ if (segment.kind === "inline" && !knownCommandIds.has(id)) {
185
+ continue;
186
+ }
187
+ refs.push({
188
+ id,
189
+ path: relPath,
190
+ command: `buildchain ${match[1]}${match[2] ? ` ${match[2]}` : ""}`,
191
+ });
192
+ }
193
+ }
194
+ }
195
+ return refs
196
+ .filter((entry) => entry.id)
197
+ .sort((a, b) => `${a.id}:${a.path}`.localeCompare(`${b.id}:${b.path}`));
198
+ }
199
+
200
+ function registryIds(entries) {
201
+ return new Set((entries || []).map((entry) => entry.id).filter(Boolean));
202
+ }
203
+
204
+ export function collectPublicSurfaceReverseAudit({
205
+ root = process.cwd(),
206
+ cliRegistry: suppliedCliRegistry = undefined,
207
+ workflowRegistry: suppliedWorkflowRegistry = undefined,
208
+ pageRegistry: suppliedPageRegistry = undefined,
209
+ } = {}) {
210
+ const cliCommands = enumerateCliCommandsFromBin({ root });
211
+ const workflowInputs = enumerateWorkflowInputs({ root });
212
+ const actionInputs = enumerateActionInputs({ root });
213
+ const sitePages = suppliedPageRegistry
214
+ ? (Array.isArray(suppliedPageRegistry?.pages) ? suppliedPageRegistry.pages : []).map((page) => ({
215
+ id: page.id || page.path,
216
+ path: page.path,
217
+ category: page.category || "",
218
+ })).sort((a, b) => String(a.id).localeCompare(String(b.id)))
219
+ : enumerateSitePages({ root });
220
+ const docCommandRefs = enumerateDocCommandRefs({ root });
221
+ const cliRegistry = suppliedCliRegistry || readJson(root, "dist/site/cli-registry.json", { commands: [] });
222
+ const workflowRegistry = suppliedWorkflowRegistry || readJson(root, "dist/site/workflow-registry.json", { workflows: [], actions: [] });
223
+ const pageRegistry = suppliedPageRegistry || readJson(root, "dist/site/page-registry.json", { pages: [] });
224
+ const declaredCli = registryIds(cliRegistry.commands);
225
+ const declaredWorkflows = registryIds(workflowRegistry.workflows);
226
+ const declaredActions = registryIds(workflowRegistry.actions);
227
+ const declaredPages = registryIds(pageRegistry.pages);
228
+ const missingCliRegistry = cliCommands.filter((entry) => !declaredCli.has(entry.id));
229
+ const missingWorkflowRegistry = workflowInputs.filter((entry) => !declaredWorkflows.has(entry.id) && entry.inputCount > 0);
230
+ const missingActionRegistry = actionInputs.filter((entry) => !declaredActions.has(entry.id) && entry.inputCount > 0);
231
+ const missingPageRegistry = sitePages.filter((entry) => entry.id && !declaredPages.has(entry.id));
232
+ const unknownDocCommandRefs = docCommandRefs.filter((entry) => !declaredCli.has(entry.id));
233
+ const failures = [
234
+ ...missingCliRegistry.map((entry) => `cli:${entry.id}`),
235
+ ...missingWorkflowRegistry.map((entry) => `workflow:${entry.id}`),
236
+ ...missingActionRegistry.map((entry) => `action:${entry.id}`),
237
+ ...missingPageRegistry.map((entry) => `site:${entry.id}`),
238
+ ...unknownDocCommandRefs.map((entry) => `doc-command:${entry.id}:${entry.path}`),
239
+ ];
240
+ const result = {
241
+ schemaVersion: 1,
242
+ contract: BUILDCHAIN_PUBLIC_SURFACE_AUDIT_CONTRACT,
243
+ status: failures.length === 0 ? "passed" : "failed",
244
+ summary: {
245
+ cliCommandCount: cliCommands.length,
246
+ workflowCount: workflowInputs.length,
247
+ actionCount: actionInputs.length,
248
+ sitePageCount: sitePages.length,
249
+ docCommandRefCount: docCommandRefs.length,
250
+ failureCount: failures.length,
251
+ },
252
+ enumerated: {
253
+ cliCommands,
254
+ workflowInputs,
255
+ actionInputs,
256
+ sitePages,
257
+ docCommandRefs,
258
+ },
259
+ declared: {
260
+ cliRegistryPath: "dist/site/cli-registry.json",
261
+ workflowRegistryPath: "dist/site/workflow-registry.json",
262
+ pageRegistryPath: "dist/site/page-registry.json",
263
+ cliRegistryDigest: fs.existsSync(path.join(root, "dist/site/cli-registry.json")) ? sha256(readText(root, "dist/site/cli-registry.json")) : "",
264
+ workflowRegistryDigest: fs.existsSync(path.join(root, "dist/site/workflow-registry.json")) ? sha256(readText(root, "dist/site/workflow-registry.json")) : "",
265
+ pageRegistryDigest: fs.existsSync(path.join(root, "dist/site/page-registry.json")) ? sha256(readText(root, "dist/site/page-registry.json")) : "",
266
+ },
267
+ comparison: {
268
+ missingCliRegistry,
269
+ missingWorkflowRegistry,
270
+ missingActionRegistry,
271
+ missingPageRegistry,
272
+ unknownDocCommandRefs,
273
+ },
274
+ auditBoundary: {
275
+ mode: "closed-world-enumerable",
276
+ scope: "Buildchain CLI usage/dispatch, reusable workflow inputs, action inputs, site pages, and documentation command references",
277
+ residualRisk: [
278
+ "Shell commands delegated through helper scripts are only counted when exposed through bin/buildchain.mjs usage or docs.",
279
+ "YAML parsing is limited to first-class action/workflow inputs, not arbitrary step environment variables.",
280
+ ],
281
+ },
282
+ };
283
+ return result;
284
+ }
285
+
286
+ export function assertPublicSurfaceReverseAudit(report) {
287
+ if (report.status !== "passed") {
288
+ const failures = [
289
+ ...report.comparison.missingCliRegistry.map((entry) => `missing CLI registry: ${entry.id}`),
290
+ ...report.comparison.missingWorkflowRegistry.map((entry) => `missing workflow registry: ${entry.id}`),
291
+ ...report.comparison.missingActionRegistry.map((entry) => `missing action registry: ${entry.id}`),
292
+ ...report.comparison.missingPageRegistry.map((entry) => `missing site page registry: ${entry.id}`),
293
+ ...report.comparison.unknownDocCommandRefs.map((entry) => `unknown docs command ref: ${entry.command} in ${entry.path}`),
294
+ ];
295
+ throw new Error(`Buildchain public surface reverse audit failed:\n${failures.join("\n")}`);
296
+ }
297
+ return report;
298
+ }
@@ -0,0 +1,242 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import {
5
+ discoverConfiguredVersionStateFiles,
6
+ getLifecycleStage,
7
+ loadBuildchainConfig,
8
+ runLifecycleStage,
9
+ updateConfiguredVersionStateContents,
10
+ } from "./buildchain-config.js";
11
+
12
+ function asPositiveInteger(value, name) {
13
+ const parsed = Number(value);
14
+ if (!Number.isInteger(parsed) || parsed < 0) {
15
+ throw new Error(`${name} must be a non-negative integer`);
16
+ }
17
+ return parsed;
18
+ }
19
+
20
+ function assertSemver(value, name) {
21
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(String(value || ""))) {
22
+ throw new Error(`${name} must be a semver version without a leading v`);
23
+ }
24
+ }
25
+
26
+ function normalizeRef(ref) {
27
+ return String(ref || "").trim().replace(/^refs\/heads\//, "");
28
+ }
29
+
30
+ function defaultSourceRef({ major, minor }) {
31
+ if (minor > 0) {
32
+ return `release/v${major}/v${major}.${minor - 1}`;
33
+ }
34
+ return `v${major}`;
35
+ }
36
+
37
+ function defaultInitialVersion({ major, minor }) {
38
+ return `${major}.${minor}.0-alpha.0`;
39
+ }
40
+
41
+ function defaultBootstrapBranch({ major, minor }) {
42
+ return `buildchain/release-line/v${major}.${minor}`;
43
+ }
44
+
45
+ function currentGitHead(cwd) {
46
+ try {
47
+ return execFileSync("git", ["rev-parse", "HEAD"], {
48
+ cwd,
49
+ encoding: "utf8",
50
+ stdio: ["ignore", "pipe", "ignore"],
51
+ }).trim();
52
+ } catch {
53
+ return "";
54
+ }
55
+ }
56
+
57
+ function readJsonIfExists(filePath) {
58
+ if (!fs.existsSync(filePath)) {
59
+ return undefined;
60
+ }
61
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
62
+ }
63
+
64
+ function writeJson(value) {
65
+ return `${JSON.stringify(value, null, 2)}\n`;
66
+ }
67
+
68
+ function discoverVersionStateFiles(cwd, loadedConfig) {
69
+ if (loadedConfig?.config?.version?.files?.length) {
70
+ return discoverConfiguredVersionStateFiles(cwd, loadedConfig);
71
+ }
72
+ const files = [];
73
+ for (const relativePath of ["lerna.json", "package.json"]) {
74
+ const filePath = path.join(cwd, relativePath);
75
+ const content = readJsonIfExists(filePath);
76
+ if (content && typeof content.version === "string") {
77
+ files.push({
78
+ path: relativePath,
79
+ kind: relativePath === "lerna.json" ? "lerna" : "package",
80
+ content,
81
+ });
82
+ }
83
+ }
84
+ return files;
85
+ }
86
+
87
+ function updateDiscoveredVersionStateContents(files, version) {
88
+ if (files.some((file) => file.type)) {
89
+ return updateConfiguredVersionStateContents(files, version);
90
+ }
91
+ return files
92
+ .map((file) => {
93
+ const next = { ...file.content, version };
94
+ const content = writeJson(next);
95
+ return {
96
+ path: file.path,
97
+ kind: file.kind,
98
+ changed: content !== writeJson(file.content),
99
+ content,
100
+ };
101
+ })
102
+ .filter((file) => file.changed);
103
+ }
104
+
105
+ function changedPaths(cwd) {
106
+ const output = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], {
107
+ cwd,
108
+ encoding: "utf8",
109
+ }).trimEnd();
110
+ return output
111
+ .split(/\r?\n/)
112
+ .filter(Boolean)
113
+ .map((line) => line.slice(3).trim())
114
+ .sort();
115
+ }
116
+
117
+ export function planReleaseLineBootstrap({
118
+ cwd = process.cwd(),
119
+ major,
120
+ minor,
121
+ sourceRef = "",
122
+ initialVersion = "",
123
+ requiredStatusCheck = "check",
124
+ setDefault = true,
125
+ createAlphaPr = true,
126
+ approvalCount = 1,
127
+ bootstrapBranch = "",
128
+ } = {}) {
129
+ const parsedMajor = asPositiveInteger(major, "major");
130
+ const parsedMinor = asPositiveInteger(minor, "minor");
131
+ const resolvedInitialVersion = initialVersion || defaultInitialVersion({ major: parsedMajor, minor: parsedMinor });
132
+ assertSemver(resolvedInitialVersion, "initialVersion");
133
+ const line = `v${parsedMajor}.${parsedMinor}`;
134
+ const devRef = `dev/v${parsedMajor}/${line}`;
135
+ const alphaRef = `alpha/v${parsedMajor}/${line}`;
136
+ const releaseRef = `release/v${parsedMajor}/${line}`;
137
+ const loadedConfig = loadBuildchainConfig(cwd);
138
+ const versionFiles = discoverVersionStateFiles(cwd, loadedConfig);
139
+ const lifecycleVersionState =
140
+ getLifecycleStage(loadedConfig, "version-state") ||
141
+ getLifecycleStage(loadedConfig, "version_state");
142
+ const source = normalizeRef(sourceRef) || defaultSourceRef({ major: parsedMajor, minor: parsedMinor });
143
+ const branch = normalizeRef(bootstrapBranch) || defaultBootstrapBranch({ major: parsedMajor, minor: parsedMinor });
144
+
145
+ return {
146
+ schemaVersion: 1,
147
+ contract: "kungfu-buildchain-release-line-bootstrap",
148
+ dryRun: true,
149
+ cwd,
150
+ line,
151
+ major: parsedMajor,
152
+ minor: parsedMinor,
153
+ initialVersion: resolvedInitialVersion,
154
+ source: {
155
+ ref: source,
156
+ sha: currentGitHead(cwd) || "",
157
+ },
158
+ refs: {
159
+ dev: devRef,
160
+ alpha: alphaRef,
161
+ release: releaseRef,
162
+ bootstrap: branch,
163
+ },
164
+ versionState: {
165
+ files: versionFiles.map((file) => file.path),
166
+ lifecycle: lifecycleVersionState ? "version-state" : "none",
167
+ },
168
+ protection: {
169
+ requiredStatusCheck,
170
+ strictStatusChecks: true,
171
+ requiredApprovingReviewCount: approvalCount,
172
+ requiredConversationResolution: true,
173
+ enforceAdmins: true,
174
+ protectedRefs: [devRef, alphaRef, releaseRef],
175
+ },
176
+ repositoryActions: [
177
+ { action: "create-or-verify-source-ref", ref: source },
178
+ { action: "commit-initial-version-state", ref: branch, version: resolvedInitialVersion },
179
+ { action: "create-dev-branch", ref: devRef, from: branch },
180
+ { action: "create-alpha-branch", ref: alphaRef, from: source },
181
+ { action: "create-release-branch", ref: releaseRef, from: source },
182
+ ...(setDefault ? [{ action: "set-default-branch", ref: devRef }] : []),
183
+ { action: "protect-branch", ref: devRef },
184
+ { action: "protect-branch", ref: alphaRef },
185
+ { action: "protect-branch", ref: releaseRef },
186
+ ...(createAlphaPr ? [{ action: "open-alpha-pr", head: devRef, base: alphaRef }] : []),
187
+ ],
188
+ notes: [
189
+ "This bootstrap creates the new minor line before the first alpha promotion.",
190
+ "The source ref stays as the baseline for alpha/release until reviewed channel PRs move them.",
191
+ "The dev branch receives only the initial version-state commit and becomes the active line when setDefault is true.",
192
+ ],
193
+ };
194
+ }
195
+
196
+ export function writeReleaseLineBootstrapVersionState({
197
+ cwd = process.cwd(),
198
+ major,
199
+ minor,
200
+ sourceRef = "",
201
+ initialVersion = "",
202
+ runVersionStateLifecycle = true,
203
+ generatedAt = "",
204
+ } = {}) {
205
+ const plan = planReleaseLineBootstrap({ cwd, major, minor, sourceRef, initialVersion });
206
+ const loadedConfig = loadBuildchainConfig(cwd);
207
+ const files = discoverVersionStateFiles(cwd, loadedConfig);
208
+ if (files.length === 0) {
209
+ throw new Error("release line bootstrap requires at least one version-state file");
210
+ }
211
+ const changed = updateDiscoveredVersionStateContents(files, plan.initialVersion);
212
+ for (const file of changed) {
213
+ fs.writeFileSync(path.join(cwd, file.path), file.content);
214
+ }
215
+ const lifecycleVersionState =
216
+ getLifecycleStage(loadedConfig, "version-state") ||
217
+ getLifecycleStage(loadedConfig, "version_state");
218
+ if (runVersionStateLifecycle && lifecycleVersionState) {
219
+ const timestamp = generatedAt || new Date().toISOString();
220
+ runLifecycleStage({
221
+ cwd,
222
+ loadedConfig,
223
+ name: "version-state",
224
+ stage: lifecycleVersionState,
225
+ env: {
226
+ BUILDCHAIN_VERSION: plan.initialVersion,
227
+ BUILDCHAIN_SITE_GENERATED_AT: timestamp,
228
+ BUILDCHAIN_SITE_PUBLISHED_AT: timestamp,
229
+ BUILDCHAIN_SITE_TIMESTAMP_POLICY: "ci-injected",
230
+ BUILDCHAIN_SURFACE_GENERATED_AT: timestamp,
231
+ BUILDCHAIN_SURFACE_PUBLISHED_AT: timestamp,
232
+ BUILDCHAIN_SURFACE_TIMESTAMP_POLICY: "ci-injected",
233
+ BUILDCHAIN_SOURCE_SHA: plan.source.sha,
234
+ },
235
+ });
236
+ }
237
+ return {
238
+ ...plan,
239
+ dryRun: false,
240
+ changedFiles: changedPaths(cwd),
241
+ };
242
+ }
@@ -1,5 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import {
4
+ assertPublicSurfaceReverseAudit,
5
+ collectPublicSurfaceReverseAudit,
6
+ } from "../packages/core/public-surface-audit.js";
3
7
 
4
8
  const root = process.cwd();
5
9
  const sharedActionTsupConfig = fs.readFileSync(path.join(root, "scripts/tsup-action.config.mjs"), "utf8");
@@ -16,6 +20,8 @@ const requiredPaths = [
16
20
  "bin/buildchain.mjs",
17
21
  "packages/core/homebrew.js",
18
22
  "packages/core/build-facts.js",
23
+ "packages/core/release-line-bootstrap.js",
24
+ "packages/core/public-surface-audit.js",
19
25
  "docs/MAP.md",
20
26
  "docs/build-facts.md",
21
27
  "docs/binary-distribution.md",
@@ -52,6 +58,7 @@ const requiredPaths = [
52
58
  ".github/actionlint.yaml",
53
59
  ".github/workflows/self-hosted-runner-smoke.yml",
54
60
  ".github/workflows/buildchain-ref-promotion.yml",
61
+ ".github/workflows/release-line-bootstrap.yml",
55
62
  ".github/workflows/dev-pr-auto-merge.yml",
56
63
  ".github/workflows/buildchain-patrol.yml",
57
64
  ".github/workflows/patrol-daily.yml",
@@ -119,6 +126,9 @@ if (rootPackage.exports?.["./buildchain-contract"] !== "./packages/core/buildcha
119
126
  if (rootPackage.exports?.["./issue-reporting"] !== "./packages/core/issue-reporting.js") {
120
127
  throw new Error("root package must export @kungfu-tech/buildchain/issue-reporting");
121
128
  }
129
+ if (rootPackage.exports?.["./release-line-bootstrap"] !== "./packages/core/release-line-bootstrap.js") {
130
+ throw new Error("root package must export @kungfu-tech/buildchain/release-line-bootstrap");
131
+ }
122
132
  if (rootPackage.exports?.["./readme-badges"] !== "./packages/core/readme-badges.js") {
123
133
  throw new Error("root package must export @kungfu-tech/buildchain/readme-badges");
124
134
  }
@@ -146,6 +156,9 @@ if (rootPackage.exports?.["./surface-manifest"] !== "./packages/core/surface-man
146
156
  if (rootPackage.exports?.["./buildchain-kfd-claims"] !== "./packages/core/buildchain-kfd-claims.js") {
147
157
  throw new Error("root package must export @kungfu-tech/buildchain/buildchain-kfd-claims");
148
158
  }
159
+ if (rootPackage.exports?.["./public-surface-audit"] !== "./packages/core/public-surface-audit.js") {
160
+ throw new Error("root package must export @kungfu-tech/buildchain/public-surface-audit");
161
+ }
149
162
  if (rootPackage.exports?.["./site/buildchain-site.json"] !== "./dist/site/buildchain-site.json") {
150
163
  throw new Error("root package must export @kungfu-tech/buildchain/site/buildchain-site.json");
151
164
  }
@@ -164,6 +177,9 @@ if (rootPackage.exports?.["./site/node-api-registry.json"] !== "./dist/site/node
164
177
  if (rootPackage.exports?.["./site/kfd-claims.json"] !== "./dist/site/kfd-claims.json") {
165
178
  throw new Error("root package must export @kungfu-tech/buildchain/site/kfd-claims.json");
166
179
  }
180
+ if (rootPackage.exports?.["./site/public-surface-audit.json"] !== "./dist/site/public-surface-audit.json") {
181
+ throw new Error("root package must export @kungfu-tech/buildchain/site/public-surface-audit.json");
182
+ }
167
183
  if (rootPackage.publishConfig?.access !== "public") {
168
184
  throw new Error("root package publishConfig.access must be public");
169
185
  }
@@ -190,6 +206,7 @@ const siteBundle = JSON.parse(fs.readFileSync(path.join(root, "dist/site/buildch
190
206
  const siteManifest = JSON.parse(fs.readFileSync(path.join(root, "dist/site/site-manifest.json"), "utf8"));
191
207
  const pageRegistry = JSON.parse(fs.readFileSync(path.join(root, "dist/site/page-registry.json"), "utf8"));
192
208
  const badgeEndpointRegistry = JSON.parse(fs.readFileSync(path.join(root, "dist/site/badge-endpoint-registry.json"), "utf8"));
209
+ const publicSurfaceAudit = JSON.parse(fs.readFileSync(path.join(root, "dist/site/public-surface-audit.json"), "utf8"));
193
210
  if (!cliSource.startsWith("#!/usr/bin/env node")) {
194
211
  throw new Error("bin/buildchain.mjs must be executable with a node shebang");
195
212
  }
@@ -306,6 +323,18 @@ if (siteBundle.pageRegistry?.path !== "page-registry.json" || siteBundle.pageReg
306
323
  if (!siteManifest.facts?.includes("page-registry.json") || !siteBundle.entrypoints?.includes("page-registry.json")) {
307
324
  throw new Error("site bundle entrypoints must include page-registry.json");
308
325
  }
326
+ if (!siteManifest.facts?.includes("public-surface-audit.json") || !siteBundle.entrypoints?.includes("public-surface-audit.json")) {
327
+ throw new Error("site bundle entrypoints must include public-surface-audit.json");
328
+ }
329
+ if (publicSurfaceAudit.contract !== "kungfu-buildchain-public-surface-reverse-audit") {
330
+ throw new Error("public-surface-audit.json must expose the reverse audit contract");
331
+ }
332
+ assertPublicSurfaceReverseAudit(publicSurfaceAudit);
333
+ const livePublicSurfaceAudit = collectPublicSurfaceReverseAudit({ root });
334
+ assertPublicSurfaceReverseAudit(livePublicSurfaceAudit);
335
+ if (JSON.stringify(publicSurfaceAudit.summary) !== JSON.stringify(livePublicSurfaceAudit.summary)) {
336
+ throw new Error("public-surface-audit.json summary is stale; run pnpm run generate:site");
337
+ }
309
338
  for (const [name, manifest] of [["buildchain-site.json", siteBundle], ["site-manifest.json", siteManifest]]) {
310
339
  if (!manifest.generatedAt) {
311
340
  throw new Error(`${name} must expose generatedAt`);
@@ -361,6 +390,8 @@ for (const requiredSnippet of [
361
390
  "createBuildchainKfd2Claims",
362
391
  "createBuildchainKfd3PrebuildWitness",
363
392
  "BUILDCHAIN_AGENT_MANUALS",
393
+ "collectPublicSurfaceReverseAudit",
394
+ "assertPublicSurfaceReverseAudit",
364
395
  ]) {
365
396
  if (!coreIndexSource.includes(requiredSnippet)) {
366
397
  throw new Error(`packages/core/index.js must export Buildchain self KFD claim API: ${requiredSnippet}`);
@@ -820,7 +851,7 @@ if (!badgeEndpointRegistry.badges?.some((entry) => entry.id === "buildchain-rele
820
851
  throw new Error("badge endpoint registry must include Buildchain Release Passport badge");
821
852
  }
822
853
 
823
- for (const siteFile of ["buildchain-site.json", "site-manifest.json", "badge-endpoint-registry.json", "cli-registry.json", "manual-registry.json", "node-api-registry.json", "release-model.json", "buildchain-contract.json"]) {
854
+ for (const siteFile of ["buildchain-site.json", "site-manifest.json", "badge-endpoint-registry.json", "cli-registry.json", "manual-registry.json", "node-api-registry.json", "workflow-registry.json", "public-surface-audit.json", "release-model.json", "buildchain-contract.json"]) {
824
855
  if (!fs.existsSync(path.join(root, "dist", "site", siteFile))) {
825
856
  throw new Error(`site bundle missing ${siteFile}`);
826
857
  }