@askexenow/exe-os 0.9.270 → 0.9.271
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/dist/bin/cli.js +1 -1
- package/dist/bin/stack-update.js +10 -3
- package/dist/bin/vps-health-gate.js +1 -1
- package/dist/chunk-IBMTSEZC.js +230 -0
- package/dist/chunk-O7YO7E2G.js +1512 -0
- package/dist/hooks/manifest.json +1 -1
- package/dist/stack-release-2KSOYDIV.js +712 -0
- package/dist/stack-update-QQA64STQ.js +52 -0
- package/package.json +1 -1
- package/release-notes.json +8 -27
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "./chunk-MLKGABMK.js";
|
|
3
|
+
|
|
4
|
+
// src/bin/stack-release.ts
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
6
|
+
import { execFile as execFileCb } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
var execFile = promisify(execFileCb);
|
|
11
|
+
function findExeOsRoot() {
|
|
12
|
+
let dir = path.dirname(new URL(import.meta.url).pathname);
|
|
13
|
+
for (let i = 0; i < 5; i++) {
|
|
14
|
+
const pkgPath = path.join(dir, "package.json");
|
|
15
|
+
if (existsSync(pkgPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
18
|
+
if (pkg.name === "@askexenow/exe-os") return dir;
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
dir = path.dirname(dir);
|
|
23
|
+
}
|
|
24
|
+
return path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..");
|
|
25
|
+
}
|
|
26
|
+
var EXE_OS_ROOT = findExeOsRoot();
|
|
27
|
+
var PARENT_DIR = path.dirname(EXE_OS_ROOT);
|
|
28
|
+
var MANIFEST_PATH = path.join(EXE_OS_ROOT, "deploy", "stack-manifests", "v0.9.json");
|
|
29
|
+
function resolveRepoPath(dirName) {
|
|
30
|
+
const direct = path.join(PARENT_DIR, dirName);
|
|
31
|
+
if (existsSync(direct)) return direct;
|
|
32
|
+
const aliases = {
|
|
33
|
+
"exe-crm": ["openclaw", "exe-crm"],
|
|
34
|
+
"exe-wiki": ["ink", "exe-wiki"]
|
|
35
|
+
};
|
|
36
|
+
const candidates = aliases[dirName];
|
|
37
|
+
if (candidates) {
|
|
38
|
+
for (const alias of candidates) {
|
|
39
|
+
const p = path.join(PARENT_DIR, alias);
|
|
40
|
+
if (existsSync(p)) return p;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return direct;
|
|
44
|
+
}
|
|
45
|
+
function buildRepoConfigs() {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
serviceKey: "exe-os",
|
|
49
|
+
ghRepo: "AskExe/exe-os",
|
|
50
|
+
localPath: EXE_OS_ROOT,
|
|
51
|
+
imageName: "update.askexe.com/askexe/exe-os",
|
|
52
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
53
|
+
simpleTag: true,
|
|
54
|
+
defaultBranch: "main"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
serviceKey: "gateway",
|
|
58
|
+
ghRepo: "AskExe/exe-gateway",
|
|
59
|
+
localPath: resolveRepoPath("exe-gateway"),
|
|
60
|
+
imageName: "update.askexe.com/askexe/exe-gateway",
|
|
61
|
+
releaseWorkflow: "release-image.yml",
|
|
62
|
+
simpleTag: true,
|
|
63
|
+
defaultBranch: "main"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
serviceKey: "crm",
|
|
67
|
+
ghRepo: "AskExe/exe-crm",
|
|
68
|
+
localPath: resolveRepoPath("exe-crm"),
|
|
69
|
+
imageName: "update.askexe.com/askexe/exe-crm",
|
|
70
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
71
|
+
simpleTag: true,
|
|
72
|
+
defaultBranch: "main"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
serviceKey: "erp",
|
|
76
|
+
ghRepo: "AskExe/exe-erp",
|
|
77
|
+
localPath: resolveRepoPath("exe-erp"),
|
|
78
|
+
imageName: "update.askexe.com/askexe/exe-erp",
|
|
79
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
80
|
+
simpleTag: true,
|
|
81
|
+
defaultBranch: "main"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
serviceKey: "wiki",
|
|
85
|
+
ghRepo: "AskExe/exe-wiki",
|
|
86
|
+
localPath: resolveRepoPath("exe-wiki"),
|
|
87
|
+
imageName: "update.askexe.com/askexe/exe-wiki",
|
|
88
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
89
|
+
simpleTag: true,
|
|
90
|
+
defaultBranch: "master"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
serviceKey: "monitor-hub",
|
|
94
|
+
ghRepo: "AskExe/exe-monitor",
|
|
95
|
+
localPath: resolveRepoPath("exe-monitor"),
|
|
96
|
+
imageName: "update.askexe.com/askexe/exe-monitor-hub",
|
|
97
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
98
|
+
simpleTag: true,
|
|
99
|
+
defaultBranch: "main"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
serviceKey: "monitor-agent",
|
|
103
|
+
ghRepo: "AskExe/exe-monitor",
|
|
104
|
+
localPath: resolveRepoPath("exe-monitor"),
|
|
105
|
+
imageName: "update.askexe.com/askexe/exe-monitor-agent",
|
|
106
|
+
releaseWorkflow: "release-stack-image.yml",
|
|
107
|
+
simpleTag: true,
|
|
108
|
+
defaultBranch: "main"
|
|
109
|
+
}
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
function parseReleaseArgs(argv) {
|
|
113
|
+
const flags = {
|
|
114
|
+
stack: false,
|
|
115
|
+
dryRun: false,
|
|
116
|
+
yes: false,
|
|
117
|
+
repos: null,
|
|
118
|
+
major: false,
|
|
119
|
+
minor: false,
|
|
120
|
+
skipImageVerify: false
|
|
121
|
+
};
|
|
122
|
+
for (let i = 0; i < argv.length; i++) {
|
|
123
|
+
const arg = argv[i];
|
|
124
|
+
if (arg === "--stack") flags.stack = true;
|
|
125
|
+
else if (arg === "--dry-run") flags.dryRun = true;
|
|
126
|
+
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
127
|
+
else if (arg === "--major") flags.major = true;
|
|
128
|
+
else if (arg === "--minor") flags.minor = true;
|
|
129
|
+
else if (arg === "--skip-image-verify") flags.skipImageVerify = true;
|
|
130
|
+
else if (arg === "--repos" && argv[i + 1]) {
|
|
131
|
+
flags.repos = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
|
|
132
|
+
} else if (arg.startsWith("--repos=")) {
|
|
133
|
+
flags.repos = arg.slice("--repos=".length).split(",").map((s) => s.trim()).filter(Boolean);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return flags;
|
|
137
|
+
}
|
|
138
|
+
async function git(repoPath, ...args) {
|
|
139
|
+
const { stdout } = await execFile("git", ["-C", repoPath, ...args]);
|
|
140
|
+
return stdout.trim();
|
|
141
|
+
}
|
|
142
|
+
async function getLastTag(repoPath) {
|
|
143
|
+
try {
|
|
144
|
+
return await git(repoPath, "describe", "--tags", "--abbrev=0");
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function getCommitsSinceTag(repoPath, tag) {
|
|
150
|
+
try {
|
|
151
|
+
const output = await git(repoPath, "log", `${tag}..HEAD`, "--oneline");
|
|
152
|
+
return output.split("\n").filter(Boolean);
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function ghRun(args) {
|
|
158
|
+
const { stdout } = await execFile("gh", args);
|
|
159
|
+
return stdout.trim();
|
|
160
|
+
}
|
|
161
|
+
function bumpVersion(version, mode) {
|
|
162
|
+
const parts = version.split(".").map(Number);
|
|
163
|
+
if (parts.length < 3) throw new Error(`Invalid version: ${version}`);
|
|
164
|
+
const [major, minor, patch] = parts;
|
|
165
|
+
switch (mode) {
|
|
166
|
+
case "major":
|
|
167
|
+
return `${major + 1}.0.0`;
|
|
168
|
+
case "minor":
|
|
169
|
+
return `${major}.${minor + 1}.0`;
|
|
170
|
+
case "patch":
|
|
171
|
+
default:
|
|
172
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function readPackageVersion(repoPath) {
|
|
176
|
+
const pkgPath = path.join(repoPath, "package.json");
|
|
177
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
178
|
+
return pkg.version;
|
|
179
|
+
}
|
|
180
|
+
function readStackReleaseJson(repoPath) {
|
|
181
|
+
const p = path.join(repoPath, "stack.release.json");
|
|
182
|
+
if (!existsSync(p)) return null;
|
|
183
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
184
|
+
}
|
|
185
|
+
async function detectChanges(repos) {
|
|
186
|
+
const changes = [];
|
|
187
|
+
for (const repo of repos) {
|
|
188
|
+
if (!existsSync(repo.localPath)) {
|
|
189
|
+
log("warn", `Repo not found: ${repo.localPath} \u2014 skipping ${repo.serviceKey}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const lastTag = await getLastTag(repo.localPath);
|
|
193
|
+
if (!lastTag) {
|
|
194
|
+
log("warn", `No tags found in ${repo.serviceKey} \u2014 skipping`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const commits = await getCommitsSinceTag(repo.localPath, lastTag);
|
|
198
|
+
if (commits.length === 0) {
|
|
199
|
+
log("info", `${repo.serviceKey}: no changes since ${lastTag}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const currentVersion = readPackageVersion(repo.localPath);
|
|
203
|
+
changes.push({
|
|
204
|
+
repo,
|
|
205
|
+
currentVersion,
|
|
206
|
+
newVersion: "",
|
|
207
|
+
// filled in step 2
|
|
208
|
+
commits,
|
|
209
|
+
tag: ""
|
|
210
|
+
// filled in step 2
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return changes;
|
|
214
|
+
}
|
|
215
|
+
async function bumpVersions(changes, mode, dryRun) {
|
|
216
|
+
for (const change of changes) {
|
|
217
|
+
const newVersion = bumpVersion(change.currentVersion, mode);
|
|
218
|
+
change.newVersion = newVersion;
|
|
219
|
+
change.tag = `v${newVersion}`;
|
|
220
|
+
if (dryRun) {
|
|
221
|
+
log("dry", `${change.repo.serviceKey}: ${change.currentVersion} -> ${newVersion}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const pkgPath = path.join(change.repo.localPath, "package.json");
|
|
225
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
226
|
+
pkg.version = newVersion;
|
|
227
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
228
|
+
const srf = readStackReleaseJson(change.repo.localPath);
|
|
229
|
+
if (srf) {
|
|
230
|
+
srf.version = newVersion;
|
|
231
|
+
srf.sourceVersion = newVersion;
|
|
232
|
+
if (typeof srf.image === "string") {
|
|
233
|
+
const imageBase = srf.image.replace(/:v[^:]+$/, "");
|
|
234
|
+
srf.image = `${imageBase}:v${newVersion}`;
|
|
235
|
+
}
|
|
236
|
+
if (srf.components && typeof srf.components === "object") {
|
|
237
|
+
const components = srf.components;
|
|
238
|
+
for (const key of Object.keys(components)) {
|
|
239
|
+
const base = components[key].replace(/:v[^:]+$/, "");
|
|
240
|
+
components[key] = `${base}:v${newVersion}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
writeFileSync(
|
|
244
|
+
path.join(change.repo.localPath, "stack.release.json"),
|
|
245
|
+
JSON.stringify(srf, null, 2) + "\n"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
await git(change.repo.localPath, "add", "package.json", "stack.release.json");
|
|
249
|
+
await git(change.repo.localPath, "commit", "-m", `chore: bump v${newVersion} for stack release`);
|
|
250
|
+
await git(change.repo.localPath, "push", "origin", change.repo.defaultBranch);
|
|
251
|
+
log("ok", `${change.repo.serviceKey}: bumped ${change.currentVersion} -> ${newVersion}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function tagAndTriggerCI(changes, dryRun) {
|
|
255
|
+
for (const change of changes) {
|
|
256
|
+
if (dryRun) {
|
|
257
|
+
log("dry", `Would tag ${change.repo.serviceKey} ${change.tag}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
await git(change.repo.localPath, "tag", change.tag);
|
|
261
|
+
await git(change.repo.localPath, "push", "origin", change.tag);
|
|
262
|
+
log("ok", `Tagged ${change.repo.serviceKey} ${change.tag} \u2014 CI triggered`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function waitForCI(changes, dryRun) {
|
|
266
|
+
if (dryRun) {
|
|
267
|
+
return changes.map((c) => ({
|
|
268
|
+
repo: c.repo,
|
|
269
|
+
tag: c.tag,
|
|
270
|
+
status: "completed",
|
|
271
|
+
conclusion: "success"
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
const TIMEOUT_MS = 15 * 60 * 1e3;
|
|
275
|
+
const POLL_INTERVAL_MS = 30 * 1e3;
|
|
276
|
+
const startTime = Date.now();
|
|
277
|
+
const statuses = changes.map((c) => ({
|
|
278
|
+
repo: c.repo,
|
|
279
|
+
tag: c.tag,
|
|
280
|
+
status: "in_progress"
|
|
281
|
+
}));
|
|
282
|
+
log("info", `Waiting for ${changes.length} CI build(s)... (timeout: 15m)`);
|
|
283
|
+
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
284
|
+
const pending = statuses.filter((s) => s.status === "in_progress");
|
|
285
|
+
if (pending.length === 0) break;
|
|
286
|
+
for (const st of pending) {
|
|
287
|
+
try {
|
|
288
|
+
const output = await ghRun([
|
|
289
|
+
"run",
|
|
290
|
+
"list",
|
|
291
|
+
"--repo",
|
|
292
|
+
st.repo.ghRepo,
|
|
293
|
+
"--workflow",
|
|
294
|
+
st.repo.releaseWorkflow,
|
|
295
|
+
"--limit",
|
|
296
|
+
"1",
|
|
297
|
+
"--json",
|
|
298
|
+
"status,conclusion,url"
|
|
299
|
+
]);
|
|
300
|
+
const runs = JSON.parse(output);
|
|
301
|
+
const latest = runs[0];
|
|
302
|
+
if (!latest) continue;
|
|
303
|
+
if (latest.status === "completed") {
|
|
304
|
+
st.status = "completed";
|
|
305
|
+
st.conclusion = latest.conclusion ?? "unknown";
|
|
306
|
+
st.url = latest.url;
|
|
307
|
+
if (st.conclusion === "success") {
|
|
308
|
+
log("ok", `CI passed: ${st.repo.serviceKey} ${st.tag}`);
|
|
309
|
+
} else {
|
|
310
|
+
log("fail", `CI failed: ${st.repo.serviceKey} ${st.tag} (${st.conclusion}) \u2014 ${st.url}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
log("warn", `Failed to check CI for ${st.repo.serviceKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const stillPending = statuses.filter((s) => s.status === "in_progress");
|
|
318
|
+
if (stillPending.length === 0) break;
|
|
319
|
+
log("info", `${stillPending.length} CI build(s) still running... (${Math.round((Date.now() - startTime) / 1e3)}s elapsed)`);
|
|
320
|
+
await sleep(POLL_INTERVAL_MS);
|
|
321
|
+
}
|
|
322
|
+
for (const st of statuses) {
|
|
323
|
+
if (st.status === "in_progress") {
|
|
324
|
+
st.status = "timed_out";
|
|
325
|
+
log("fail", `CI timed out: ${st.repo.serviceKey} ${st.tag}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return statuses;
|
|
329
|
+
}
|
|
330
|
+
var PROXY_REGISTRY = "update.askexe.com";
|
|
331
|
+
var GHCR_REGISTRY = "ghcr.io";
|
|
332
|
+
function shortImageName(imageName) {
|
|
333
|
+
const parts = imageName.split("/");
|
|
334
|
+
return parts[parts.length - 1] ?? imageName;
|
|
335
|
+
}
|
|
336
|
+
async function checkRegistryManifest(registry, repo, tag, token) {
|
|
337
|
+
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
|
338
|
+
const headers = [
|
|
339
|
+
"-H",
|
|
340
|
+
"Accept: application/vnd.oci.image.index.v1+json"
|
|
341
|
+
];
|
|
342
|
+
if (token) {
|
|
343
|
+
headers.push("-H", `Authorization: Bearer ${token}`);
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const { stdout } = await execFile("curl", [
|
|
347
|
+
"-sS",
|
|
348
|
+
"-m",
|
|
349
|
+
"30",
|
|
350
|
+
"-o",
|
|
351
|
+
"/dev/null",
|
|
352
|
+
"-w",
|
|
353
|
+
"%{http_code}",
|
|
354
|
+
...headers,
|
|
355
|
+
url
|
|
356
|
+
]);
|
|
357
|
+
const code = parseInt(stdout.trim(), 10);
|
|
358
|
+
return { status: code, ok: code >= 200 && code < 300 };
|
|
359
|
+
} catch {
|
|
360
|
+
return { status: 0, ok: false };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function warmProxyCache(imageRef) {
|
|
364
|
+
try {
|
|
365
|
+
await execFile("docker", ["pull", imageRef], { timeout: 12e4 });
|
|
366
|
+
return true;
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function verifyImages(changes, ciStatuses, dryRun) {
|
|
372
|
+
if (dryRun) {
|
|
373
|
+
return changes.map((c) => ({
|
|
374
|
+
repo: c.repo,
|
|
375
|
+
tag: `v${c.newVersion}`,
|
|
376
|
+
ghcr: "ok",
|
|
377
|
+
proxy: "ok"
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
const proxyToken = process.env.EXE_REGISTRY_PROXY_PULL_TOKEN ?? void 0;
|
|
381
|
+
const results = [];
|
|
382
|
+
log("info", "Verifying Docker images are pullable...");
|
|
383
|
+
for (const change of changes) {
|
|
384
|
+
const ci = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
385
|
+
if (!ci || ci.status !== "completed" || ci.conclusion !== "success") continue;
|
|
386
|
+
const tag = `v${change.newVersion}`;
|
|
387
|
+
const shortName = shortImageName(change.repo.imageName);
|
|
388
|
+
const proxyRepo = `askexe/${shortName}`;
|
|
389
|
+
const ghcrRepo = `askexe/${shortName}`;
|
|
390
|
+
const result = {
|
|
391
|
+
repo: change.repo,
|
|
392
|
+
tag,
|
|
393
|
+
ghcr: "error",
|
|
394
|
+
proxy: "error"
|
|
395
|
+
};
|
|
396
|
+
const proxyCheck = await checkRegistryManifest(PROXY_REGISTRY, proxyRepo, tag, proxyToken);
|
|
397
|
+
if (proxyCheck.ok) {
|
|
398
|
+
result.proxy = "ok";
|
|
399
|
+
} else {
|
|
400
|
+
result.proxy = "pending";
|
|
401
|
+
}
|
|
402
|
+
let ghcrToken = process.env.GHCR_TOKEN?.trim() || void 0;
|
|
403
|
+
if (!ghcrToken) {
|
|
404
|
+
try {
|
|
405
|
+
const { stdout } = await execFile("gh", ["auth", "token"]);
|
|
406
|
+
ghcrToken = stdout.trim();
|
|
407
|
+
} catch {
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const ghcrCheck = await checkRegistryManifest(GHCR_REGISTRY, ghcrRepo, tag, ghcrToken);
|
|
411
|
+
if (ghcrCheck.ok) {
|
|
412
|
+
result.ghcr = "ok";
|
|
413
|
+
} else if (ghcrCheck.status === 401 || ghcrCheck.status === 403) {
|
|
414
|
+
result.ghcr = "error";
|
|
415
|
+
result.detail = `GHCR auth failed (${ghcrCheck.status}) \u2014 cannot verify image. Fix gh auth or GHCR_TOKEN.`;
|
|
416
|
+
log("fail", `GHCR auth returned ${ghcrCheck.status} for ${shortName}:${tag} \u2014 cannot verify, treating as error`);
|
|
417
|
+
} else {
|
|
418
|
+
result.ghcr = "missing";
|
|
419
|
+
result.detail = `GHCR returned ${ghcrCheck.status} for ${ghcrRepo}:${tag}`;
|
|
420
|
+
}
|
|
421
|
+
if (result.ghcr === "ok" && result.proxy !== "ok") {
|
|
422
|
+
log("info", `Warming proxy cache for ${shortName}:${tag}...`);
|
|
423
|
+
const proxyRef = `${PROXY_REGISTRY}/${proxyRepo}:${tag}`;
|
|
424
|
+
const warmed = await warmProxyCache(proxyRef);
|
|
425
|
+
if (warmed) {
|
|
426
|
+
result.proxy = "ok";
|
|
427
|
+
log("ok", `Proxy cache warmed: ${shortName}:${tag}`);
|
|
428
|
+
} else {
|
|
429
|
+
result.proxy = "pending";
|
|
430
|
+
log("warn", `Could not warm proxy for ${shortName}:${tag} \u2014 GHCR has it, proxy will cache on first customer pull`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (result.ghcr === "ok" && result.proxy === "ok") {
|
|
434
|
+
log("ok", `Image verified: ${shortName}:${tag} (GHCR + proxy)`);
|
|
435
|
+
} else if (result.ghcr === "ok" && result.proxy === "pending") {
|
|
436
|
+
log("warn", `Image on GHCR only: ${shortName}:${tag} (proxy will cache on first pull)`);
|
|
437
|
+
} else if (result.ghcr === "missing") {
|
|
438
|
+
log("fail", `Image NOT FOUND on GHCR: ${shortName}:${tag} \u2014 release may be broken!`);
|
|
439
|
+
}
|
|
440
|
+
results.push(result);
|
|
441
|
+
}
|
|
442
|
+
return results;
|
|
443
|
+
}
|
|
444
|
+
function printImageVerification(results) {
|
|
445
|
+
if (results.length === 0) return;
|
|
446
|
+
console.log("\n Image verification:");
|
|
447
|
+
for (const r of results) {
|
|
448
|
+
const shortName = shortImageName(r.repo.imageName);
|
|
449
|
+
const ghcrIcon = r.ghcr === "ok" ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
|
|
450
|
+
const proxyIcon = r.proxy === "ok" ? "\x1B[32m\u2713\x1B[0m" : r.proxy === "pending" ? "\x1B[33m~\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
|
|
451
|
+
const proxyLabel = r.proxy === "pending" ? "pending (first pull will cache)" : r.proxy;
|
|
452
|
+
console.log(
|
|
453
|
+
` ${shortName}:${r.tag}`.padEnd(40) + `${ghcrIcon} GHCR ${proxyIcon} proxy${r.proxy === "pending" ? ` (${proxyLabel})` : ""}`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
console.log("");
|
|
457
|
+
}
|
|
458
|
+
function updateStackManifest(changes, ciStatuses, dryRun, imageResults) {
|
|
459
|
+
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
460
|
+
const currentLatest = manifest.latest;
|
|
461
|
+
const currentParts = currentLatest.split(".").map(Number);
|
|
462
|
+
const newStackVersion = `${currentParts[0]}.${currentParts[1]}.${(currentParts[2] ?? 0) + 1}`;
|
|
463
|
+
const baseEntry = manifest.stacks[currentLatest];
|
|
464
|
+
const baseServices = baseEntry?.services ?? {};
|
|
465
|
+
const services = {};
|
|
466
|
+
for (const [key, svc] of Object.entries(baseServices)) {
|
|
467
|
+
services[key] = { ...svc };
|
|
468
|
+
}
|
|
469
|
+
const successfulChanges = [];
|
|
470
|
+
for (const change of changes) {
|
|
471
|
+
const ciStatus = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
472
|
+
if (ciStatus && ciStatus.status === "completed" && ciStatus.conclusion === "success") {
|
|
473
|
+
successfulChanges.push(change);
|
|
474
|
+
const svc = services[change.repo.serviceKey];
|
|
475
|
+
if (svc) {
|
|
476
|
+
const imageTag = `${change.repo.imageName}:v${change.newVersion}`;
|
|
477
|
+
const imgResult = imageResults?.find((r) => r.repo.serviceKey === change.repo.serviceKey);
|
|
478
|
+
svc.image = imgResult?.digest ? `${imageTag}@${imgResult.digest}` : imageTag;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (successfulChanges.length === 0 && !dryRun) {
|
|
483
|
+
log("warn", "No successful CI builds \u2014 manifest not updated");
|
|
484
|
+
return currentLatest;
|
|
485
|
+
}
|
|
486
|
+
const notesParts = [];
|
|
487
|
+
for (const change of successfulChanges) {
|
|
488
|
+
const summaries = change.commits.slice(0, 5).map((c) => {
|
|
489
|
+
const msg = c.replace(/^[a-f0-9]+ /, "");
|
|
490
|
+
return msg;
|
|
491
|
+
});
|
|
492
|
+
notesParts.push(`${change.repo.serviceKey} v${change.newVersion}: ${summaries.join("; ")}`);
|
|
493
|
+
}
|
|
494
|
+
const notes = notesParts.join(". ");
|
|
495
|
+
const exeOsVersion = readPackageVersion(EXE_OS_ROOT);
|
|
496
|
+
const newEntry = {
|
|
497
|
+
version: newStackVersion,
|
|
498
|
+
releasedAt: (/* @__PURE__ */ new Date()).toISOString().replace(/T.*/, "T00:00:00Z"),
|
|
499
|
+
notes,
|
|
500
|
+
npmVersion: exeOsVersion,
|
|
501
|
+
breakingChanges: [],
|
|
502
|
+
services
|
|
503
|
+
};
|
|
504
|
+
if (dryRun) {
|
|
505
|
+
log("dry", `Would create stack ${newStackVersion} in manifest`);
|
|
506
|
+
log("dry", `Notes: ${notes}`);
|
|
507
|
+
return newStackVersion;
|
|
508
|
+
}
|
|
509
|
+
manifest.latest = newStackVersion;
|
|
510
|
+
manifest.stacks[newStackVersion] = newEntry;
|
|
511
|
+
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n");
|
|
512
|
+
log("ok", `Stack manifest updated: ${currentLatest} -> ${newStackVersion}`);
|
|
513
|
+
return newStackVersion;
|
|
514
|
+
}
|
|
515
|
+
async function commitAndTagExeOs(newStackVersion, _exeOsChange, dryRun) {
|
|
516
|
+
if (dryRun) {
|
|
517
|
+
log("dry", `Would commit manifest + tag exe-os stack-v${newStackVersion}`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const status = await git(EXE_OS_ROOT, "status", "--porcelain", "deploy/stack-manifests/v0.9.json");
|
|
521
|
+
if (status) {
|
|
522
|
+
await git(EXE_OS_ROOT, "add", "deploy/stack-manifests/v0.9.json");
|
|
523
|
+
const commitMsg = `chore: stack release v${newStackVersion}`;
|
|
524
|
+
await git(EXE_OS_ROOT, "commit", "-m", commitMsg);
|
|
525
|
+
await git(EXE_OS_ROOT, "push", "origin", "main");
|
|
526
|
+
log("ok", `Committed stack manifest v${newStackVersion}`);
|
|
527
|
+
}
|
|
528
|
+
const stackTag = `stack-v${newStackVersion}`;
|
|
529
|
+
try {
|
|
530
|
+
await git(EXE_OS_ROOT, "tag", stackTag);
|
|
531
|
+
await git(EXE_OS_ROOT, "push", "origin", stackTag);
|
|
532
|
+
log("ok", `Tagged exe-os ${stackTag}`);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
log("warn", `Failed to tag exe-os ${stackTag}: ${err instanceof Error ? err.message : String(err)}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function printSummary(changes, ciStatuses, newStackVersion, dryRun) {
|
|
538
|
+
const divider = "\u2500".repeat(70);
|
|
539
|
+
console.log(`
|
|
540
|
+
${divider}`);
|
|
541
|
+
console.log(` ${dryRun ? "[DRY RUN] " : ""}Stack Release Summary \u2014 v${newStackVersion}`);
|
|
542
|
+
console.log(divider);
|
|
543
|
+
console.log(
|
|
544
|
+
` ${"Service".padEnd(14)} ${"Version".padEnd(20)} ${"Commits".padEnd(8)} ${"CI".padEnd(12)}`
|
|
545
|
+
);
|
|
546
|
+
console.log(` ${"\u2500".repeat(14)} ${"\u2500".repeat(20)} ${"\u2500".repeat(8)} ${"\u2500".repeat(12)}`);
|
|
547
|
+
for (const change of changes) {
|
|
548
|
+
const ci = ciStatuses.find((s) => s.repo.serviceKey === change.repo.serviceKey);
|
|
549
|
+
const ciLabel = dryRun ? "\x1B[33mpending\x1B[0m" : ci?.status === "completed" && ci.conclusion === "success" ? "\x1B[32msuccess\x1B[0m" : ci?.status === "timed_out" ? "\x1B[31mtimed out\x1B[0m" : `\x1B[31m${ci?.conclusion ?? ci?.status ?? "unknown"}\x1B[0m`;
|
|
550
|
+
const versionStr = `${change.currentVersion} -> ${change.newVersion}`;
|
|
551
|
+
console.log(
|
|
552
|
+
` ${change.repo.serviceKey.padEnd(14)} ${versionStr.padEnd(20)} ${String(change.commits.length).padEnd(8)} ${ciLabel}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (changes.length === 0) {
|
|
556
|
+
console.log(" No changes detected across any repo.");
|
|
557
|
+
}
|
|
558
|
+
console.log(divider);
|
|
559
|
+
if (!dryRun) {
|
|
560
|
+
const failed = ciStatuses.filter(
|
|
561
|
+
(s) => s.status !== "completed" || s.conclusion !== "success"
|
|
562
|
+
);
|
|
563
|
+
if (failed.length > 0) {
|
|
564
|
+
console.log("\n \x1B[31mWarning:\x1B[0m Some CI builds failed. Check the links above.");
|
|
565
|
+
} else if (changes.length > 0) {
|
|
566
|
+
console.log(
|
|
567
|
+
`
|
|
568
|
+
\x1B[32mAll done.\x1B[0m Customers will pull new images from update.askexe.com on next stack-update.`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
console.log("");
|
|
573
|
+
}
|
|
574
|
+
function log(level, msg) {
|
|
575
|
+
const prefix = {
|
|
576
|
+
info: "\x1B[36m[info]\x1B[0m",
|
|
577
|
+
ok: "\x1B[32m[ok]\x1B[0m",
|
|
578
|
+
warn: "\x1B[33m[warn]\x1B[0m",
|
|
579
|
+
fail: "\x1B[31m[fail]\x1B[0m",
|
|
580
|
+
dry: "\x1B[35m[dry-run]\x1B[0m"
|
|
581
|
+
};
|
|
582
|
+
console.log(` ${prefix[level]} ${msg}`);
|
|
583
|
+
}
|
|
584
|
+
function sleep(ms) {
|
|
585
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
586
|
+
}
|
|
587
|
+
async function confirm(message) {
|
|
588
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
589
|
+
return new Promise((resolve) => {
|
|
590
|
+
rl.question(`
|
|
591
|
+
${message} [y/N] `, (answer) => {
|
|
592
|
+
rl.close();
|
|
593
|
+
resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
async function runStackRelease(flags) {
|
|
598
|
+
if (!flags.stack) {
|
|
599
|
+
console.error("Usage: exe-os release --stack [--dry-run] [--repos <list>] [--yes] [--skip-image-verify]");
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
console.log(`
|
|
603
|
+
\x1B[1mexe-os stack release\x1B[0m${flags.dryRun ? " (dry run)" : ""}
|
|
604
|
+
`);
|
|
605
|
+
const allRepos = buildRepoConfigs();
|
|
606
|
+
let repos;
|
|
607
|
+
if (flags.repos) {
|
|
608
|
+
repos = [];
|
|
609
|
+
for (const name of flags.repos) {
|
|
610
|
+
const match = allRepos.find(
|
|
611
|
+
(r) => r.serviceKey === name || r.ghRepo.endsWith(`/${name}`) || r.ghRepo === name
|
|
612
|
+
);
|
|
613
|
+
if (match) {
|
|
614
|
+
repos.push(match);
|
|
615
|
+
} else {
|
|
616
|
+
log("warn", `Unknown repo: ${name} \u2014 skipping`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (repos.length === 0) {
|
|
620
|
+
console.error("No valid repos specified.");
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
repos = allRepos;
|
|
625
|
+
}
|
|
626
|
+
for (const repo of repos) {
|
|
627
|
+
if (!existsSync(repo.localPath)) {
|
|
628
|
+
log("fail", `Repo not found: ${repo.localPath} (${repo.serviceKey})`);
|
|
629
|
+
log("info", `Expected sibling directory of exe-os. Verify the directory exists.`);
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
log("info", "Detecting changes across repos...");
|
|
634
|
+
const changes = await detectChanges(repos);
|
|
635
|
+
if (changes.length === 0) {
|
|
636
|
+
console.log("\n No changes detected since last tags. Nothing to release.\n");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const bumpMode = flags.major ? "major" : flags.minor ? "minor" : "patch";
|
|
640
|
+
for (const change of changes) {
|
|
641
|
+
const newVersion = bumpVersion(change.currentVersion, bumpMode);
|
|
642
|
+
change.newVersion = newVersion;
|
|
643
|
+
change.tag = `v${newVersion}`;
|
|
644
|
+
}
|
|
645
|
+
console.log("\n Planned releases:");
|
|
646
|
+
for (const change of changes) {
|
|
647
|
+
console.log(
|
|
648
|
+
` ${change.repo.serviceKey.padEnd(14)} ${change.currentVersion} -> ${change.newVersion} (${change.commits.length} commit${change.commits.length === 1 ? "" : "s"})`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (!flags.yes && !flags.dryRun) {
|
|
652
|
+
const ok = await confirm("Proceed with release?");
|
|
653
|
+
if (!ok) {
|
|
654
|
+
console.log(" Aborted.\n");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
await bumpVersions(changes, bumpMode, flags.dryRun);
|
|
659
|
+
log("info", "Tagging repos and triggering CI...");
|
|
660
|
+
await tagAndTriggerCI(changes, flags.dryRun);
|
|
661
|
+
const ciStatuses = await waitForCI(changes, flags.dryRun);
|
|
662
|
+
let imageResults = [];
|
|
663
|
+
if (flags.skipImageVerify) {
|
|
664
|
+
log("warn", "Image verification skipped (--skip-image-verify)");
|
|
665
|
+
} else {
|
|
666
|
+
imageResults = await verifyImages(changes, ciStatuses, flags.dryRun);
|
|
667
|
+
const failed = imageResults.filter((r) => (r.ghcr === "missing" || r.ghcr === "error") && r.proxy !== "ok");
|
|
668
|
+
if (failed.length > 0 && !flags.dryRun) {
|
|
669
|
+
for (const m of failed) {
|
|
670
|
+
log("fail", `${shortImageName(m.repo.imageName)}:${m.tag} \u2014 GHCR status: ${m.ghcr}, proxy: ${m.proxy} \u2014 cannot release`);
|
|
671
|
+
}
|
|
672
|
+
log("fail", "Aborting: images not verified. Fix GHCR auth/CI or use --skip-image-verify to override.");
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
const pending = imageResults.filter((r) => r.proxy === "pending");
|
|
676
|
+
if (pending.length > 0) {
|
|
677
|
+
for (const p of pending) {
|
|
678
|
+
log("warn", `${shortImageName(p.repo.imageName)}:${p.tag} \u2014 proxy cache pending`);
|
|
679
|
+
}
|
|
680
|
+
if (!flags.dryRun) {
|
|
681
|
+
log("fail", "Aborting: proxy cache not ready for all images. Wait for proxy propagation or use --skip-image-verify.");
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
printImageVerification(imageResults);
|
|
686
|
+
}
|
|
687
|
+
log("info", "Updating stack manifest...");
|
|
688
|
+
const newStackVersion = updateStackManifest(changes, ciStatuses, flags.dryRun, imageResults);
|
|
689
|
+
const exeOsChange = changes.find((c) => c.repo.serviceKey === "exe-os");
|
|
690
|
+
await commitAndTagExeOs(newStackVersion, exeOsChange, flags.dryRun);
|
|
691
|
+
printSummary(changes, ciStatuses, newStackVersion, flags.dryRun);
|
|
692
|
+
if (!flags.skipImageVerify && !flags.dryRun && imageResults.length > 0) {
|
|
693
|
+
const pendingImages = imageResults.filter((r) => r.proxy === "pending");
|
|
694
|
+
if (pendingImages.length > 0) {
|
|
695
|
+
log("info", "Re-checking proxy availability for pending images...");
|
|
696
|
+
for (const r of pendingImages) {
|
|
697
|
+
const shortName = shortImageName(r.repo.imageName);
|
|
698
|
+
const proxyRepo = `askexe/${shortName}`;
|
|
699
|
+
const proxyToken = process.env.EXE_REGISTRY_PROXY_PULL_TOKEN ?? void 0;
|
|
700
|
+
const recheck = await checkRegistryManifest(PROXY_REGISTRY, proxyRepo, r.tag, proxyToken);
|
|
701
|
+
if (recheck.ok) {
|
|
702
|
+
r.proxy = "ok";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
printImageVerification(imageResults);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
export {
|
|
710
|
+
parseReleaseArgs,
|
|
711
|
+
runStackRelease
|
|
712
|
+
};
|