@kungfu-tech/buildchain 0.0.0-bootstrap.0 → 2.0.13-alpha.10
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 +262 -0
- package/bin/buildchain.mjs +222 -0
- package/docs/cli.md +124 -0
- package/docs/lifecycle-protocol.md +422 -0
- package/docs/publish-transaction.md +285 -0
- package/docs/reusable-build-surface.md +350 -0
- package/docs/web-surface-deployments.md +211 -0
- package/package.json +52 -1
- package/packages/core/README.md +15 -0
- package/packages/core/buildchain-config.js +721 -0
- package/packages/core/index.js +40 -0
- package/packages/core/package-manager.js +291 -0
- package/packages/core/publish-transaction.js +418 -0
- package/packages/core/release-line-dry-run.js +296 -0
- package/scripts/aggregate-build-summary.mjs +88 -0
- package/scripts/build-contract-core.mjs +731 -0
- package/scripts/check-inventory.mjs +325 -0
- package/scripts/init-repo.mjs +316 -0
- package/scripts/npm-publish-dry-run.mjs +176 -0
- package/scripts/npm-publish-transaction.mjs +268 -0
- package/scripts/publish-source-ref-resolver.mjs +113 -0
- package/scripts/release-line-dry-run.mjs +53 -0
- package/scripts/release-line-policy.mjs +141 -0
- package/scripts/release-transaction.mjs +212 -0
- package/scripts/resolve-build-contract.mjs +63 -0
- package/scripts/resolve-publish-gate.mjs +33 -0
- package/scripts/resolve-publish-source.mjs +99 -0
- package/scripts/run-lifecycle-core.mjs +162 -0
- package/scripts/run-lifecycle.mjs +40 -0
- package/scripts/strip-trailing-whitespace.mjs +14 -0
- package/scripts/tsup-action.config.mjs +19 -0
- package/scripts/verify-publish-source-lock.mjs +37 -0
- package/scripts/verify-release-pr.mjs +34 -0
- package/scripts/web-surface-core.mjs +382 -0
- package/scripts/web-surface.mjs +112 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
loadBuildchainConfig,
|
|
8
|
+
normalizeLifecycleStage,
|
|
9
|
+
runLifecycleStage,
|
|
10
|
+
} from "../packages/core/buildchain-config.js";
|
|
11
|
+
import {
|
|
12
|
+
createArtifactSummary,
|
|
13
|
+
parseExpectedArtifactsJson,
|
|
14
|
+
validateExpectedArtifacts,
|
|
15
|
+
} from "./build-contract-core.mjs";
|
|
16
|
+
|
|
17
|
+
function sha256File(filePath) {
|
|
18
|
+
const hash = crypto.createHash("sha256");
|
|
19
|
+
hash.update(fs.readFileSync(filePath));
|
|
20
|
+
return hash.digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toPosix(value) {
|
|
24
|
+
return String(value || "").split(path.sep).join("/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function listFiles(root, rel) {
|
|
28
|
+
const target = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
29
|
+
if (!fs.existsSync(target)) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const stat = fs.statSync(target);
|
|
33
|
+
if (stat.isFile()) {
|
|
34
|
+
return [target];
|
|
35
|
+
}
|
|
36
|
+
return fs
|
|
37
|
+
.readdirSync(target, { withFileTypes: true })
|
|
38
|
+
.flatMap((entry) => listFiles(root, path.join(target, entry.name)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function manifestPathFor(root, filePath) {
|
|
42
|
+
const relative = path.relative(root, filePath);
|
|
43
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
44
|
+
return toPosix(relative);
|
|
45
|
+
}
|
|
46
|
+
return toPosix(path.resolve(filePath));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectArtifactFiles(root, patterns) {
|
|
50
|
+
const files = new Set();
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
const clean = pattern.replace(/\\/g, "/").replace(/\/\*\*\/?\*?$/, "");
|
|
53
|
+
for (const file of listFiles(root, clean)) {
|
|
54
|
+
files.add(path.resolve(file));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...files].sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runLifecycle({
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
stageName = "",
|
|
63
|
+
command = "",
|
|
64
|
+
required = false,
|
|
65
|
+
manifestPath = ".buildchain/artifacts/manifest.json",
|
|
66
|
+
summaryPath = ".buildchain/artifacts/summary.json",
|
|
67
|
+
artifactName = "buildchain-artifact",
|
|
68
|
+
platformId = os.platform(),
|
|
69
|
+
platformName = platformId,
|
|
70
|
+
artifactPaths = [],
|
|
71
|
+
expectedArtifactsJson = "",
|
|
72
|
+
workspace = process.cwd(),
|
|
73
|
+
} = {}) {
|
|
74
|
+
const resolvedCwd = path.resolve(cwd);
|
|
75
|
+
const resolvedWorkspace = path.resolve(workspace);
|
|
76
|
+
const resolvedManifestPath = path.resolve(resolvedWorkspace, manifestPath);
|
|
77
|
+
const resolvedSummaryPath = path.resolve(resolvedWorkspace, summaryPath);
|
|
78
|
+
const loadedConfig = loadBuildchainConfig(resolvedCwd);
|
|
79
|
+
let commandSource = "none";
|
|
80
|
+
let executed = false;
|
|
81
|
+
|
|
82
|
+
if (command.trim()) {
|
|
83
|
+
commandSource = "workflow-input";
|
|
84
|
+
execSync(command, {
|
|
85
|
+
cwd: resolvedCwd,
|
|
86
|
+
env: process.env,
|
|
87
|
+
shell: true,
|
|
88
|
+
stdio: "inherit",
|
|
89
|
+
});
|
|
90
|
+
executed = true;
|
|
91
|
+
} else if (stageName) {
|
|
92
|
+
commandSource = "buildchain.toml";
|
|
93
|
+
executed = runLifecycleStage({
|
|
94
|
+
cwd: resolvedCwd,
|
|
95
|
+
loadedConfig,
|
|
96
|
+
name: stageName,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (required && !executed) {
|
|
101
|
+
throw new Error(`required lifecycle stage did not run: ${stageName || "command"}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fs.mkdirSync(path.dirname(resolvedManifestPath), { recursive: true });
|
|
105
|
+
const files = collectArtifactFiles(resolvedWorkspace, artifactPaths);
|
|
106
|
+
const manifestFiles = files.map((file) => {
|
|
107
|
+
const stat = fs.statSync(file);
|
|
108
|
+
return {
|
|
109
|
+
path: manifestPathFor(resolvedWorkspace, file),
|
|
110
|
+
size: stat.size,
|
|
111
|
+
sha256: sha256File(file),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
const platform = {
|
|
115
|
+
id: platformId,
|
|
116
|
+
name: platformName,
|
|
117
|
+
os: process.env.RUNNER_OS || os.platform(),
|
|
118
|
+
arch: process.env.RUNNER_ARCH || os.arch(),
|
|
119
|
+
};
|
|
120
|
+
const summary = createArtifactSummary({
|
|
121
|
+
artifactName,
|
|
122
|
+
platform,
|
|
123
|
+
files: manifestFiles,
|
|
124
|
+
});
|
|
125
|
+
const expected = parseExpectedArtifactsJson(expectedArtifactsJson);
|
|
126
|
+
const expectedArtifacts = validateExpectedArtifacts({
|
|
127
|
+
expected,
|
|
128
|
+
files: manifestFiles,
|
|
129
|
+
summary,
|
|
130
|
+
});
|
|
131
|
+
const manifest = {
|
|
132
|
+
schemaVersion: 1,
|
|
133
|
+
contract: "kungfu-buildchain-artifact",
|
|
134
|
+
artifactName,
|
|
135
|
+
platform,
|
|
136
|
+
git: {
|
|
137
|
+
repository: process.env.GITHUB_REPOSITORY || "",
|
|
138
|
+
sha: process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA || "",
|
|
139
|
+
ref: process.env.BUILDCHAIN_SOURCE_REF || process.env.GITHUB_REF || "",
|
|
140
|
+
runId: process.env.GITHUB_RUN_ID || "",
|
|
141
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || "",
|
|
142
|
+
},
|
|
143
|
+
lifecycle: {
|
|
144
|
+
stage: stageName,
|
|
145
|
+
commandSource,
|
|
146
|
+
executed,
|
|
147
|
+
},
|
|
148
|
+
summary,
|
|
149
|
+
expectedArtifacts,
|
|
150
|
+
files: manifestFiles,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
fs.writeFileSync(resolvedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
154
|
+
fs.mkdirSync(path.dirname(resolvedSummaryPath), { recursive: true });
|
|
155
|
+
fs.writeFileSync(resolvedSummaryPath, `${JSON.stringify(summary, null, 2)}\n`);
|
|
156
|
+
console.log(`buildchain_manifest=${path.relative(resolvedWorkspace, resolvedManifestPath)}`);
|
|
157
|
+
return manifest;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function normalizeCommandStage(commandText) {
|
|
161
|
+
return normalizeLifecycleStage({ command: commandText }, "workflow command");
|
|
162
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { runLifecycle } from "./run-lifecycle-core.mjs";
|
|
5
|
+
|
|
6
|
+
function readArg(name, fallback = "") {
|
|
7
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
8
|
+
if (index === -1) {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
return process.argv[index + 1] || "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseList(value) {
|
|
15
|
+
return String(value || "")
|
|
16
|
+
.split(/\r?\n/)
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function runLifecycleCli() {
|
|
22
|
+
return runLifecycle({
|
|
23
|
+
cwd: readArg("cwd", process.cwd()),
|
|
24
|
+
stageName: readArg("stage"),
|
|
25
|
+
command: process.env.BUILDCHAIN_COMMAND || "",
|
|
26
|
+
required: readArg("required", "false") === "true",
|
|
27
|
+
manifestPath: readArg("manifest-path", ".buildchain/artifacts/manifest.json"),
|
|
28
|
+
summaryPath: readArg("summary-path", ".buildchain/artifacts/summary.json"),
|
|
29
|
+
artifactName: readArg("artifact-name", "buildchain-artifact"),
|
|
30
|
+
platformId: readArg("platform-id", os.platform()),
|
|
31
|
+
platformName: readArg("platform-name", readArg("platform-id", os.platform())),
|
|
32
|
+
artifactPaths: parseList(process.env.BUILDCHAIN_ARTIFACT_PATHS || ""),
|
|
33
|
+
expectedArtifactsJson: process.env.BUILDCHAIN_EXPECTED_ARTIFACTS_JSON || "",
|
|
34
|
+
workspace: process.cwd(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
39
|
+
runLifecycleCli();
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
const files = process.argv.slice(2);
|
|
4
|
+
if (files.length === 0) {
|
|
5
|
+
throw new Error("usage: node scripts/strip-trailing-whitespace.mjs <file> [...]");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
const content = fs.readFileSync(file, "utf8");
|
|
10
|
+
const normalized = content.replace(/[ \t]+$/gm, "");
|
|
11
|
+
if (normalized !== content) {
|
|
12
|
+
fs.writeFileSync(file, normalized);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
target: "node24",
|
|
5
|
+
platform: "node",
|
|
6
|
+
banner: {
|
|
7
|
+
js: "import { createRequire as __buildchainCreateRequire } from 'node:module';\nconst require = __buildchainCreateRequire(import.meta.url);",
|
|
8
|
+
},
|
|
9
|
+
splitting: false,
|
|
10
|
+
sourcemap: false,
|
|
11
|
+
minify: true,
|
|
12
|
+
clean: true,
|
|
13
|
+
outDir: "dist",
|
|
14
|
+
skipNodeModulesBundle: false,
|
|
15
|
+
noExternal: [/.*/],
|
|
16
|
+
esbuildOptions(options) {
|
|
17
|
+
options.legalComments = "none";
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { parsePublishSourceRef, verifyPublishSourceLock } from "./build-contract-core.mjs";
|
|
4
|
+
import { normalizeSourceRef, resolvePublishSourceRefSha } from "./publish-source-ref-resolver.mjs";
|
|
5
|
+
|
|
6
|
+
function readEnv(env, name, fallback = "") {
|
|
7
|
+
return env[name] || fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function verifyPublishSourceLockCli({
|
|
11
|
+
env = process.env,
|
|
12
|
+
fetchImpl = globalThis.fetch,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const sourceRef = normalizeSourceRef(readEnv(env, "BUILDCHAIN_PUBLISH_SOURCE_REF"));
|
|
15
|
+
const parsed = parsePublishSourceRef(sourceRef);
|
|
16
|
+
if (!parsed.enabled) {
|
|
17
|
+
return { ok: true, sourceRef, skipped: true };
|
|
18
|
+
}
|
|
19
|
+
const repository = readEnv(env, "BUILDCHAIN_SOURCE_REPOSITORY", readEnv(env, "GITHUB_REPOSITORY"));
|
|
20
|
+
const currentSha = readEnv(env, "BUILDCHAIN_CURRENT_SOURCE_SHA")
|
|
21
|
+
|| await resolvePublishSourceRefSha({ repository, sourceRef: parsed.sourceRef, env, fetchImpl });
|
|
22
|
+
return verifyPublishSourceLock({
|
|
23
|
+
sourceRef: parsed.sourceRef,
|
|
24
|
+
expectedSha: readEnv(env, "BUILDCHAIN_PUBLISH_SOURCE_SHA"),
|
|
25
|
+
currentSha,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
30
|
+
try {
|
|
31
|
+
const result = await verifyPublishSourceLockCli();
|
|
32
|
+
console.log(JSON.stringify(result));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { getBumpKeyword, normalizeRef, readCurrentVersion } from "./release-line-policy.mjs";
|
|
4
|
+
|
|
5
|
+
function readEnv(name, fallback = "") {
|
|
6
|
+
return String(process.env[name] || fallback).trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function writeOutput(name, value) {
|
|
10
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
11
|
+
if (!outputPath) {
|
|
12
|
+
console.log(`${name}=${value}`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
fs.appendFileSync(outputPath, `${name}=${value}${os.EOL}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cwd = readEnv("BUILDCHAIN_SOURCE_CWD", process.cwd());
|
|
19
|
+
const headRef = readEnv("BUILDCHAIN_HEAD_REF", process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME);
|
|
20
|
+
const baseRef = readEnv("BUILDCHAIN_BASE_REF", process.env.GITHUB_BASE_REF || process.env.GITHUB_REF_NAME);
|
|
21
|
+
|
|
22
|
+
if (!headRef || !baseRef) {
|
|
23
|
+
throw new Error("BUILDCHAIN_HEAD_REF/GITHUB_HEAD_REF and BUILDCHAIN_BASE_REF/GITHUB_BASE_REF are required");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const keyword = getBumpKeyword({ cwd, headRef, baseRef });
|
|
27
|
+
const version = readCurrentVersion(cwd);
|
|
28
|
+
|
|
29
|
+
writeOutput("keyword", keyword);
|
|
30
|
+
writeOutput("version", version.version);
|
|
31
|
+
writeOutput("head-ref", normalizeRef(headRef));
|
|
32
|
+
writeOutput("base-ref", normalizeRef(baseRef));
|
|
33
|
+
|
|
34
|
+
console.log(`release PR verified: ${normalizeRef(headRef)} -> ${normalizeRef(baseRef)} (${keyword}, ${version.version})`);
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { loadBuildchainConfig, validateBuildchainConfig } from "../packages/core/buildchain-config.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_RETENTION = Object.freeze({
|
|
8
|
+
preview: {
|
|
9
|
+
pr_days: 14,
|
|
10
|
+
sha_days: 90,
|
|
11
|
+
},
|
|
12
|
+
staging: {
|
|
13
|
+
days: 90,
|
|
14
|
+
keep_deploys: 10,
|
|
15
|
+
},
|
|
16
|
+
production: {
|
|
17
|
+
days: 365,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function assertWebSurfaceConfig(loadedConfig) {
|
|
22
|
+
if (loadedConfig?.config?.project?.type !== "web-surface") {
|
|
23
|
+
throw new Error('buildchain.toml project.type must be "web-surface"');
|
|
24
|
+
}
|
|
25
|
+
return loadedConfig.config;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertSha(value, label) {
|
|
29
|
+
const sha = String(value || "").trim();
|
|
30
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
31
|
+
throw new Error(`${label} must be a 40-character Git SHA`);
|
|
32
|
+
}
|
|
33
|
+
return sha.toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertHash(value, label) {
|
|
37
|
+
const hash = String(value || "").trim();
|
|
38
|
+
if (!/^[0-9a-f]{64}$/i.test(hash)) {
|
|
39
|
+
throw new Error(`${label} must be a 64-character SHA-256 hash`);
|
|
40
|
+
}
|
|
41
|
+
return hash.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toPosix(value) {
|
|
45
|
+
return String(value || "").split(path.sep).join("/");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listFiles(root, rel) {
|
|
49
|
+
const target = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
50
|
+
if (!fs.existsSync(target)) {
|
|
51
|
+
throw new Error(`artifact path does not exist: ${rel}`);
|
|
52
|
+
}
|
|
53
|
+
const stat = fs.statSync(target);
|
|
54
|
+
if (stat.isFile()) {
|
|
55
|
+
return [target];
|
|
56
|
+
}
|
|
57
|
+
return fs
|
|
58
|
+
.readdirSync(target, { withFileTypes: true })
|
|
59
|
+
.flatMap((entry) => listFiles(root, path.join(target, entry.name)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sha256File(filePath) {
|
|
63
|
+
const hash = crypto.createHash("sha256");
|
|
64
|
+
hash.update(fs.readFileSync(filePath));
|
|
65
|
+
return hash.digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addDays(iso, days) {
|
|
69
|
+
if (days === undefined || days === null) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
const date = new Date(iso);
|
|
73
|
+
date.setUTCDate(date.getUTCDate() + Number(days));
|
|
74
|
+
return date.toISOString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function retentionConfig(config, channel) {
|
|
78
|
+
return {
|
|
79
|
+
...(DEFAULT_RETENTION[channel] || {}),
|
|
80
|
+
...(config.retention?.[channel] || {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function classifyPreviewAlias(alias) {
|
|
85
|
+
if (/^pr-\d+$/.test(alias)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: "pr",
|
|
88
|
+
mutable: true,
|
|
89
|
+
retentionClass: "preview-pr-ephemeral",
|
|
90
|
+
retentionKey: "pr_days",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (/^sha-[0-9a-f]{6,40}$/i.test(alias)) {
|
|
94
|
+
return {
|
|
95
|
+
kind: "sha",
|
|
96
|
+
mutable: false,
|
|
97
|
+
retentionClass: "preview-sha-immutable",
|
|
98
|
+
retentionKey: "sha_days",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
kind: "custom",
|
|
103
|
+
mutable: true,
|
|
104
|
+
retentionClass: "preview-custom-ephemeral",
|
|
105
|
+
retentionKey: "pr_days",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveChannelUrl(channel, alias) {
|
|
110
|
+
if (channel.urlPattern) {
|
|
111
|
+
if (!alias) {
|
|
112
|
+
throw new Error(`channel ${channel.name} requires alias for url_pattern`);
|
|
113
|
+
}
|
|
114
|
+
return channel.urlPattern.replaceAll("{alias}", alias);
|
|
115
|
+
}
|
|
116
|
+
return channel.url;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function retentionFor({ config, channelName, alias, deployedAt }) {
|
|
120
|
+
if (channelName === "preview") {
|
|
121
|
+
const classified = classifyPreviewAlias(alias);
|
|
122
|
+
const days = retentionConfig(config, "preview")[classified.retentionKey];
|
|
123
|
+
return {
|
|
124
|
+
aliasKind: classified.kind,
|
|
125
|
+
mutableAlias: classified.mutable,
|
|
126
|
+
retentionClass: classified.retentionClass,
|
|
127
|
+
expiresAt: addDays(deployedAt, days),
|
|
128
|
+
retentionDays: days,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (channelName === "staging") {
|
|
132
|
+
const retention = retentionConfig(config, "staging");
|
|
133
|
+
return {
|
|
134
|
+
aliasKind: "",
|
|
135
|
+
mutableAlias: true,
|
|
136
|
+
retentionClass: "staging-protected",
|
|
137
|
+
expiresAt: addDays(deployedAt, retention.days),
|
|
138
|
+
retentionDays: retention.days,
|
|
139
|
+
keepDeploys: retention.keep_deploys,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const retention = retentionConfig(config, "production");
|
|
143
|
+
return {
|
|
144
|
+
aliasKind: "",
|
|
145
|
+
mutableAlias: false,
|
|
146
|
+
retentionClass: "production-canonical",
|
|
147
|
+
expiresAt: addDays(deployedAt, retention.days),
|
|
148
|
+
retentionDays: retention.days,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function validateWebSurfaceProject(cwd = process.cwd()) {
|
|
153
|
+
const summary = validateBuildchainConfig(cwd, {
|
|
154
|
+
requireConfig: true,
|
|
155
|
+
});
|
|
156
|
+
if (summary.project?.type !== "web-surface") {
|
|
157
|
+
throw new Error('buildchain.toml project.type must be "web-surface"');
|
|
158
|
+
}
|
|
159
|
+
return summary;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createWebSurfaceArtifactHash({ cwd = process.cwd(), artifactPath = "" } = {}) {
|
|
163
|
+
const root = path.resolve(cwd);
|
|
164
|
+
const files = listFiles(root, artifactPath || ".")
|
|
165
|
+
.sort()
|
|
166
|
+
.map((filePath) => {
|
|
167
|
+
const stat = fs.statSync(filePath);
|
|
168
|
+
const relative = toPosix(path.relative(root, filePath));
|
|
169
|
+
return {
|
|
170
|
+
path: relative,
|
|
171
|
+
size: stat.size,
|
|
172
|
+
sha256: sha256File(filePath),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
const digest = crypto.createHash("sha256");
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
digest.update(`${file.path}\0${file.size}\0${file.sha256}\n`);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
artifactHash: digest.digest("hex"),
|
|
181
|
+
files,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createWebSurfaceDeploymentManifest({
|
|
186
|
+
cwd = process.cwd(),
|
|
187
|
+
channel = "preview",
|
|
188
|
+
alias = "",
|
|
189
|
+
sourceSha = "",
|
|
190
|
+
artifactHash = "",
|
|
191
|
+
deployedAt = new Date().toISOString(),
|
|
192
|
+
deploymentId = "",
|
|
193
|
+
runtimeId = "",
|
|
194
|
+
configFingerprint = "",
|
|
195
|
+
healthCheck = "",
|
|
196
|
+
migrationState = "",
|
|
197
|
+
rollbackPointer = "",
|
|
198
|
+
rollbackLimitations = "",
|
|
199
|
+
} = {}) {
|
|
200
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
201
|
+
const config = assertWebSurfaceConfig(loadedConfig);
|
|
202
|
+
const channelConfig = config.channels?.[channel];
|
|
203
|
+
if (!channelConfig) {
|
|
204
|
+
throw new Error(`unknown web-surface channel: ${channel}`);
|
|
205
|
+
}
|
|
206
|
+
const deployConfig = config.deploy?.[channel];
|
|
207
|
+
if (!deployConfig) {
|
|
208
|
+
throw new Error(`missing deploy.${channel}`);
|
|
209
|
+
}
|
|
210
|
+
const retention = retentionFor({ config, channelName: channel, alias, deployedAt });
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
contract: "kungfu-buildchain-web-surface-deployment",
|
|
214
|
+
site: config.project.site || config.project.name || "",
|
|
215
|
+
channel,
|
|
216
|
+
alias,
|
|
217
|
+
url: resolveChannelUrl(channelConfig, alias),
|
|
218
|
+
sourceSha: assertSha(sourceSha, "sourceSha"),
|
|
219
|
+
artifactHash: assertHash(artifactHash, "artifactHash"),
|
|
220
|
+
deployTarget: deployConfig.target || deployConfig.bucket || deployConfig.environment || deployConfig.service || "",
|
|
221
|
+
adapter: deployConfig.adapter,
|
|
222
|
+
deploymentId,
|
|
223
|
+
deployedAt,
|
|
224
|
+
retentionClass: retention.retentionClass,
|
|
225
|
+
expiresAt: retention.expiresAt,
|
|
226
|
+
mutableAlias: retention.mutableAlias,
|
|
227
|
+
runtimeId,
|
|
228
|
+
configFingerprint,
|
|
229
|
+
secretRefs: [
|
|
230
|
+
...new Set([
|
|
231
|
+
...(deployConfig.secretRefs || []),
|
|
232
|
+
...(config.security?.[channel]?.secretRefs || []),
|
|
233
|
+
]),
|
|
234
|
+
],
|
|
235
|
+
healthCheck,
|
|
236
|
+
migrationState,
|
|
237
|
+
rollbackPointer,
|
|
238
|
+
rollbackLimitations,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function planWebSurfaceDeploy({
|
|
243
|
+
cwd = process.cwd(),
|
|
244
|
+
channel = "preview",
|
|
245
|
+
alias = "",
|
|
246
|
+
sourceSha = "",
|
|
247
|
+
artifactHash = "",
|
|
248
|
+
artifactPath = "",
|
|
249
|
+
dryRun = true,
|
|
250
|
+
deployedAt = new Date().toISOString(),
|
|
251
|
+
} = {}) {
|
|
252
|
+
if (!dryRun) {
|
|
253
|
+
throw new Error("web-surface deploy currently supports dry-run planning only");
|
|
254
|
+
}
|
|
255
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
256
|
+
const config = assertWebSurfaceConfig(loadedConfig);
|
|
257
|
+
const deployConfig = config.deploy?.[channel];
|
|
258
|
+
if (!deployConfig) {
|
|
259
|
+
throw new Error(`missing deploy.${channel}`);
|
|
260
|
+
}
|
|
261
|
+
const resolvedArtifact = artifactHash
|
|
262
|
+
? { artifactHash: assertHash(artifactHash, "artifactHash"), files: [] }
|
|
263
|
+
: createWebSurfaceArtifactHash({
|
|
264
|
+
cwd,
|
|
265
|
+
artifactPath: artifactPath || deployConfig.artifactPath || ".",
|
|
266
|
+
});
|
|
267
|
+
const manifest = createWebSurfaceDeploymentManifest({
|
|
268
|
+
cwd,
|
|
269
|
+
channel,
|
|
270
|
+
alias,
|
|
271
|
+
sourceSha,
|
|
272
|
+
artifactHash: resolvedArtifact.artifactHash,
|
|
273
|
+
deployedAt,
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: 1,
|
|
277
|
+
contract: "kungfu-buildchain-web-surface-deploy-plan",
|
|
278
|
+
dryRun: true,
|
|
279
|
+
adapter: deployConfig.adapter,
|
|
280
|
+
channel,
|
|
281
|
+
alias,
|
|
282
|
+
url: manifest.url,
|
|
283
|
+
artifact: {
|
|
284
|
+
path: artifactPath || deployConfig.artifactPath || ".",
|
|
285
|
+
hash: resolvedArtifact.artifactHash,
|
|
286
|
+
files: resolvedArtifact.files,
|
|
287
|
+
},
|
|
288
|
+
manifest,
|
|
289
|
+
steps: planAdapterSteps(deployConfig.adapter, deployConfig, manifest),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function planAdapterSteps(adapter, deployConfig, manifest) {
|
|
294
|
+
if (adapter === "aws-s3-cloudfront") {
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
action: "sync-static-artifact",
|
|
298
|
+
target: deployConfig.bucket || deployConfig.target || "",
|
|
299
|
+
prefix: deployConfig.prefix || manifest.alias || manifest.channel,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
action: "write-deployment-manifest",
|
|
303
|
+
target: deployConfig.manifest_prefix || ".buildchain/deployments",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
action: "invalidate-cdn",
|
|
307
|
+
distribution: deployConfig.cloudfront_distribution || deployConfig.distribution || "",
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
}
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
action: "prepare-dynamic-environment",
|
|
314
|
+
target: manifest.deployTarget,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
action: "write-deployment-manifest",
|
|
318
|
+
target: deployConfig.manifest_prefix || ".buildchain/deployments",
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function planWebSurfaceCleanup({
|
|
324
|
+
cwd = process.cwd(),
|
|
325
|
+
aliases = [],
|
|
326
|
+
channel = "preview",
|
|
327
|
+
now = new Date().toISOString(),
|
|
328
|
+
dryRun = true,
|
|
329
|
+
} = {}) {
|
|
330
|
+
if (!dryRun) {
|
|
331
|
+
throw new Error("web-surface cleanup currently supports dry-run planning only");
|
|
332
|
+
}
|
|
333
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
334
|
+
const config = assertWebSurfaceConfig(loadedConfig);
|
|
335
|
+
if (channel !== "preview") {
|
|
336
|
+
throw new Error("web-surface cleanup plan currently supports preview aliases only");
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
contract: "kungfu-buildchain-web-surface-cleanup-plan",
|
|
341
|
+
dryRun: true,
|
|
342
|
+
channel,
|
|
343
|
+
now,
|
|
344
|
+
entries: aliases.map((alias) => {
|
|
345
|
+
const classified = classifyPreviewAlias(alias);
|
|
346
|
+
const retention = retentionConfig(config, "preview");
|
|
347
|
+
return {
|
|
348
|
+
alias,
|
|
349
|
+
aliasKind: classified.kind,
|
|
350
|
+
mutableAlias: classified.mutable,
|
|
351
|
+
retentionClass: classified.retentionClass,
|
|
352
|
+
retentionDays: retention[classified.retentionKey],
|
|
353
|
+
action: "expire-preview-alias",
|
|
354
|
+
};
|
|
355
|
+
}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function defaultWebSurfaceAlias({ channel = "preview", sourceSha = "", pullNumber = "" } = {}) {
|
|
360
|
+
if (channel !== "preview") {
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
if (pullNumber) {
|
|
364
|
+
return `pr-${pullNumber}`;
|
|
365
|
+
}
|
|
366
|
+
const sha = assertSha(sourceSha, "sourceSha");
|
|
367
|
+
return `sha-${sha.slice(0, 12)}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function localWebSurfaceContext() {
|
|
371
|
+
return {
|
|
372
|
+
sourceSha: process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA || "",
|
|
373
|
+
sourceRef: process.env.BUILDCHAIN_SOURCE_REF || process.env.GITHUB_REF || "",
|
|
374
|
+
repository: process.env.GITHUB_REPOSITORY || "",
|
|
375
|
+
runId: process.env.GITHUB_RUN_ID || "",
|
|
376
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || "",
|
|
377
|
+
runner: {
|
|
378
|
+
os: process.env.RUNNER_OS || os.platform(),
|
|
379
|
+
arch: process.env.RUNNER_ARCH || os.arch(),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|