@kody-ade/kody-engine 0.3.69 → 0.3.70-beta.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.
- package/dist/bin/kody.js +200 -3
- package/dist/executables/release/deploy.sh +127 -0
- package/dist/executables/release/prepare.sh +345 -0
- package/dist/executables/release/profile.json +50 -46
- package/dist/executables/release/publish.sh +60 -0
- package/dist/executables/release/release.sh +169 -0
- package/dist/executables/release/wait.sh +88 -0
- package/dist/executables/types.ts +40 -2
- package/package.json +1 -1
package/dist/bin/kody.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@kody-ade/kody-engine",
|
|
6
|
-
version: "0.3.
|
|
6
|
+
version: "0.3.70-beta.2",
|
|
7
7
|
description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
|
|
8
8
|
license: "MIT",
|
|
9
9
|
type: "module",
|
|
@@ -1122,7 +1122,8 @@ import * as fs8 from "fs";
|
|
|
1122
1122
|
import * as path7 from "path";
|
|
1123
1123
|
var VALID_INPUT_TYPES = /* @__PURE__ */ new Set(["int", "string", "bool", "enum"]);
|
|
1124
1124
|
var VALID_PERMISSION_MODES = /* @__PURE__ */ new Set(["default", "acceptEdits", "plan", "bypassPermissions"]);
|
|
1125
|
-
var VALID_ROLES = /* @__PURE__ */ new Set(["primitive", "orchestrator", "watch", "utility"]);
|
|
1125
|
+
var VALID_ROLES = /* @__PURE__ */ new Set(["primitive", "orchestrator", "container", "watch", "utility"]);
|
|
1126
|
+
var VALID_CONTAINER_CHILD_TARGETS = /* @__PURE__ */ new Set(["issue", "pr"]);
|
|
1126
1127
|
var VALID_PHASES = /* @__PURE__ */ new Set(["research", "planning", "implementing", "reviewing", "shipped", "failed", "idle"]);
|
|
1127
1128
|
var ProfileError = class extends Error {
|
|
1128
1129
|
constructor(profilePath, message) {
|
|
@@ -1162,6 +1163,7 @@ function loadProfile(profilePath) {
|
|
|
1162
1163
|
}
|
|
1163
1164
|
phase = r.phase;
|
|
1164
1165
|
}
|
|
1166
|
+
const children = parseContainerChildren(profilePath, role, r.children);
|
|
1165
1167
|
const profile = {
|
|
1166
1168
|
name: requireString(profilePath, r, "name"),
|
|
1167
1169
|
describe: typeof r.describe === "string" ? r.describe : "",
|
|
@@ -1176,6 +1178,7 @@ function loadProfile(profilePath) {
|
|
|
1176
1178
|
outputContract: r.outputContract,
|
|
1177
1179
|
inputArtifacts: parseInputArtifacts(profilePath, r.input),
|
|
1178
1180
|
outputArtifacts: parseOutputArtifacts(profilePath, r.output),
|
|
1181
|
+
children,
|
|
1179
1182
|
dir: path7.dirname(profilePath)
|
|
1180
1183
|
};
|
|
1181
1184
|
return profile;
|
|
@@ -1336,6 +1339,45 @@ function parseOutputArtifacts(p, raw) {
|
|
|
1336
1339
|
}
|
|
1337
1340
|
return out;
|
|
1338
1341
|
}
|
|
1342
|
+
function parseContainerChildren(p, role, raw) {
|
|
1343
|
+
const isContainer = role === "container";
|
|
1344
|
+
const present = raw !== void 0 && raw !== null;
|
|
1345
|
+
if (!isContainer) {
|
|
1346
|
+
if (!present) return void 0;
|
|
1347
|
+
if (Array.isArray(raw) && raw.length === 0) return void 0;
|
|
1348
|
+
throw new ProfileError(p, `"children" is only allowed when role === "container"`);
|
|
1349
|
+
}
|
|
1350
|
+
if (!present) {
|
|
1351
|
+
throw new ProfileError(p, `role: "container" requires a non-empty "children" array`);
|
|
1352
|
+
}
|
|
1353
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
1354
|
+
throw new ProfileError(p, `role: "container" requires a non-empty "children" array`);
|
|
1355
|
+
}
|
|
1356
|
+
const out = [];
|
|
1357
|
+
for (const [i, item] of raw.entries()) {
|
|
1358
|
+
if (!item || typeof item !== "object") {
|
|
1359
|
+
throw new ProfileError(p, `children[${i}] must be an object { exec, target, next }`);
|
|
1360
|
+
}
|
|
1361
|
+
const r = item;
|
|
1362
|
+
const exec = requireString(p, r, "exec");
|
|
1363
|
+
const target = requireString(p, r, "target");
|
|
1364
|
+
if (!VALID_CONTAINER_CHILD_TARGETS.has(target)) {
|
|
1365
|
+
throw new ProfileError(p, `children[${i}].target must be "issue" or "pr"`);
|
|
1366
|
+
}
|
|
1367
|
+
if (!r.next || typeof r.next !== "object" || Array.isArray(r.next)) {
|
|
1368
|
+
throw new ProfileError(p, `children[${i}].next must be an object mapping action types to next step`);
|
|
1369
|
+
}
|
|
1370
|
+
const next = {};
|
|
1371
|
+
for (const [k, v] of Object.entries(r.next)) {
|
|
1372
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
1373
|
+
throw new ProfileError(p, `children[${i}].next["${k}"] must be a non-empty string`);
|
|
1374
|
+
}
|
|
1375
|
+
next[k] = v;
|
|
1376
|
+
}
|
|
1377
|
+
out.push({ exec, target, next });
|
|
1378
|
+
}
|
|
1379
|
+
return out;
|
|
1380
|
+
}
|
|
1339
1381
|
function parseScriptList(p, key, raw) {
|
|
1340
1382
|
if (!Array.isArray(raw)) {
|
|
1341
1383
|
throw new ProfileError(p, `scripts.${key} must be an array`);
|
|
@@ -6878,6 +6920,7 @@ function runShell(cmd, cwd, timeoutMs = 3e4) {
|
|
|
6878
6920
|
}
|
|
6879
6921
|
|
|
6880
6922
|
// src/executor.ts
|
|
6923
|
+
var CONTAINER_MAX_ITERATIONS = 50;
|
|
6881
6924
|
async function runExecutable(profileName, input) {
|
|
6882
6925
|
const profilePath = resolveProfilePath(profileName);
|
|
6883
6926
|
const profile = loadProfile(profilePath);
|
|
@@ -6977,7 +7020,10 @@ async function runExecutable(profileName, input) {
|
|
|
6977
7020
|
}
|
|
6978
7021
|
}
|
|
6979
7022
|
let agentResult = null;
|
|
6980
|
-
if (
|
|
7023
|
+
if (profile.role === "container") {
|
|
7024
|
+
ctx.skipAgent = true;
|
|
7025
|
+
await runContainerLoop(profile, ctx, input);
|
|
7026
|
+
} else if (!ctx.skipAgent) {
|
|
6981
7027
|
const prompt = ctx.data.prompt;
|
|
6982
7028
|
if (!prompt) {
|
|
6983
7029
|
return finish({ exitCode: 99, reason: "composePrompt did not produce a prompt (ctx.data.prompt missing)" });
|
|
@@ -7254,6 +7300,157 @@ async function runShellEntry(entry, ctx, profile) {
|
|
|
7254
7300
|
function envKey(name) {
|
|
7255
7301
|
return name.toUpperCase().replace(/-/g, "_");
|
|
7256
7302
|
}
|
|
7303
|
+
async function runContainerLoop(profile, ctx, input) {
|
|
7304
|
+
const children = profile.children;
|
|
7305
|
+
if (!children || children.length === 0) {
|
|
7306
|
+
process.stderr.write(`[kody container] profile "${profile.name}" has no children \u2014 nothing to run
|
|
7307
|
+
`);
|
|
7308
|
+
ctx.output.exitCode = 0;
|
|
7309
|
+
ctx.output.reason = "container has no children";
|
|
7310
|
+
return;
|
|
7311
|
+
}
|
|
7312
|
+
const runChild = input.__runChild ?? ((name, opts) => runExecutable(name, opts));
|
|
7313
|
+
const reader = input.__readTaskState ?? readTaskState;
|
|
7314
|
+
const issueNumber = ctx.args.issue;
|
|
7315
|
+
let currentIdx = 0;
|
|
7316
|
+
let iteration = 0;
|
|
7317
|
+
while (currentIdx >= 0 && currentIdx < children.length) {
|
|
7318
|
+
iteration++;
|
|
7319
|
+
if (iteration > CONTAINER_MAX_ITERATIONS) {
|
|
7320
|
+
const reason = `container exceeded ${CONTAINER_MAX_ITERATIONS} iterations \u2014 possible routing loop`;
|
|
7321
|
+
process.stderr.write(`[kody container] aborting: ${reason}
|
|
7322
|
+
`);
|
|
7323
|
+
ctx.output.exitCode = 1;
|
|
7324
|
+
ctx.output.reason = reason;
|
|
7325
|
+
return;
|
|
7326
|
+
}
|
|
7327
|
+
const child = children[currentIdx];
|
|
7328
|
+
process.stderr.write(`[kody container] step ${iteration}: invoking ${child.exec}
|
|
7329
|
+
`);
|
|
7330
|
+
const priorState = readContainerState(ctx, child, reader);
|
|
7331
|
+
const priorAction = priorState.executables?.[child.exec]?.lastAction;
|
|
7332
|
+
let actionType;
|
|
7333
|
+
if (priorAction && /_COMPLETED$/i.test(priorAction.type)) {
|
|
7334
|
+
process.stderr.write(`[kody container] skipping ${child.exec}: already completed (${priorAction.type})
|
|
7335
|
+
`);
|
|
7336
|
+
actionType = priorAction.type;
|
|
7337
|
+
} else {
|
|
7338
|
+
let cliArgs;
|
|
7339
|
+
if (child.target === "pr") {
|
|
7340
|
+
const prUrl = priorState.core?.prUrl;
|
|
7341
|
+
const prNumber = prUrl ? parsePrNumber4(prUrl) : null;
|
|
7342
|
+
if (!prNumber) {
|
|
7343
|
+
const reason = `container child "${child.exec}" needs --pr but state.core.prUrl is unset`;
|
|
7344
|
+
process.stderr.write(`[kody container] aborting: ${reason}
|
|
7345
|
+
`);
|
|
7346
|
+
ctx.output.exitCode = 1;
|
|
7347
|
+
ctx.output.reason = reason;
|
|
7348
|
+
const action = {
|
|
7349
|
+
type: "AGENT_NOT_RUN",
|
|
7350
|
+
payload: { reason, dispatchTarget: "pr", child: child.exec },
|
|
7351
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
7352
|
+
};
|
|
7353
|
+
ctx.data.action = action;
|
|
7354
|
+
return;
|
|
7355
|
+
}
|
|
7356
|
+
cliArgs = { pr: prNumber };
|
|
7357
|
+
} else {
|
|
7358
|
+
if (issueNumber === void 0) {
|
|
7359
|
+
const reason = `container child "${child.exec}" needs --issue but ctx.args.issue is unset`;
|
|
7360
|
+
process.stderr.write(`[kody container] aborting: ${reason}
|
|
7361
|
+
`);
|
|
7362
|
+
ctx.output.exitCode = 1;
|
|
7363
|
+
ctx.output.reason = reason;
|
|
7364
|
+
return;
|
|
7365
|
+
}
|
|
7366
|
+
cliArgs = { issue: issueNumber };
|
|
7367
|
+
}
|
|
7368
|
+
let childOut;
|
|
7369
|
+
try {
|
|
7370
|
+
childOut = await runChild(child.exec, {
|
|
7371
|
+
cliArgs,
|
|
7372
|
+
cwd: input.cwd,
|
|
7373
|
+
config: input.config,
|
|
7374
|
+
skipConfig: input.skipConfig,
|
|
7375
|
+
verbose: input.verbose,
|
|
7376
|
+
quiet: input.quiet
|
|
7377
|
+
});
|
|
7378
|
+
} catch (err) {
|
|
7379
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7380
|
+
process.stderr.write(`[kody container] child "${child.exec}" crashed: ${msg}
|
|
7381
|
+
`);
|
|
7382
|
+
ctx.output.exitCode = 1;
|
|
7383
|
+
ctx.output.reason = `child "${child.exec}" crashed: ${msg}`;
|
|
7384
|
+
return;
|
|
7385
|
+
}
|
|
7386
|
+
const next = readContainerState(ctx, child, reader);
|
|
7387
|
+
ctx.data.taskState = next;
|
|
7388
|
+
const actionFromState = next.core?.lastOutcome?.type;
|
|
7389
|
+
actionType = actionFromState ?? (childOut.exitCode === 0 ? "RUN_COMPLETED" : "RUN_FAILED");
|
|
7390
|
+
}
|
|
7391
|
+
const route = child.next[actionType] ?? child.next["*"];
|
|
7392
|
+
if (!route) {
|
|
7393
|
+
const reason = `no route for action "${actionType}" from child "${child.exec}"`;
|
|
7394
|
+
process.stderr.write(`[kody container] aborting: ${reason}
|
|
7395
|
+
`);
|
|
7396
|
+
ctx.output.exitCode = 1;
|
|
7397
|
+
ctx.output.reason = reason;
|
|
7398
|
+
return;
|
|
7399
|
+
}
|
|
7400
|
+
process.stderr.write(`[kody container] outcome ${actionType}: dispatching to ${route}
|
|
7401
|
+
`);
|
|
7402
|
+
if (route === "done") {
|
|
7403
|
+
ctx.output.exitCode = 0;
|
|
7404
|
+
return;
|
|
7405
|
+
}
|
|
7406
|
+
if (route === "abort") {
|
|
7407
|
+
ctx.output.exitCode = 1;
|
|
7408
|
+
ctx.output.reason = `container aborted by route from "${child.exec}" on ${actionType}`;
|
|
7409
|
+
return;
|
|
7410
|
+
}
|
|
7411
|
+
const nextIdx = children.findIndex((c) => c.exec === route);
|
|
7412
|
+
if (nextIdx < 0) {
|
|
7413
|
+
const reason = `container route "${route}" does not match any declared child exec name`;
|
|
7414
|
+
process.stderr.write(`[kody container] aborting: ${reason}
|
|
7415
|
+
`);
|
|
7416
|
+
ctx.output.exitCode = 1;
|
|
7417
|
+
ctx.output.reason = reason;
|
|
7418
|
+
return;
|
|
7419
|
+
}
|
|
7420
|
+
currentIdx = nextIdx;
|
|
7421
|
+
}
|
|
7422
|
+
}
|
|
7423
|
+
function readContainerState(ctx, _child, reader) {
|
|
7424
|
+
const issueNumber = ctx.args.issue;
|
|
7425
|
+
if (issueNumber !== void 0) {
|
|
7426
|
+
try {
|
|
7427
|
+
return reader("issue", issueNumber, ctx.cwd);
|
|
7428
|
+
} catch {
|
|
7429
|
+
}
|
|
7430
|
+
}
|
|
7431
|
+
if (ctx.data.taskState && typeof ctx.data.taskState === "object") {
|
|
7432
|
+
return ctx.data.taskState;
|
|
7433
|
+
}
|
|
7434
|
+
return {
|
|
7435
|
+
schemaVersion: 1,
|
|
7436
|
+
core: {
|
|
7437
|
+
phase: "idle",
|
|
7438
|
+
status: "pending",
|
|
7439
|
+
currentExecutable: null,
|
|
7440
|
+
lastOutcome: null,
|
|
7441
|
+
attempts: {}
|
|
7442
|
+
},
|
|
7443
|
+
executables: {},
|
|
7444
|
+
artifacts: {},
|
|
7445
|
+
history: []
|
|
7446
|
+
};
|
|
7447
|
+
}
|
|
7448
|
+
function parsePrNumber4(url) {
|
|
7449
|
+
const m = url.match(/\/pull\/(\d+)(?:[/?#]|$)/);
|
|
7450
|
+
if (!m) return null;
|
|
7451
|
+
const n = parseInt(m[1], 10);
|
|
7452
|
+
return Number.isFinite(n) ? n : null;
|
|
7453
|
+
}
|
|
7257
7454
|
function flattenConfig(obj, prefix = "") {
|
|
7258
7455
|
const out = [];
|
|
7259
7456
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# release/deploy.sh — function library for the deploy phase.
|
|
4
|
+
#
|
|
5
|
+
# Functions:
|
|
6
|
+
# read_changelog_section <branch> <ver> -> prints the body of the matching ## header block
|
|
7
|
+
# build_pr_body <ver> <changelog> <default_branch> <release_branch> <issue>
|
|
8
|
+
# -> echoes the deploy PR body
|
|
9
|
+
# open_deploy_pr <new_version> <issue> -> echoes deploy PR URL (or empty if no-op)
|
|
10
|
+
|
|
11
|
+
# shellcheck disable=SC2148
|
|
12
|
+
|
|
13
|
+
# Extract the section for $ver from CHANGELOG.md fetched from origin/$branch.
|
|
14
|
+
# Handles three header shapes:
|
|
15
|
+
# "## [0.25.0] - 2026-04-15" (bracketed, dash separator)
|
|
16
|
+
# "## 0.22.0 (2026-03-25)" (bare, parenthesized date)
|
|
17
|
+
# "## v0.25.5 — 2026-05-05" (v-prefixed, em-dash, kody style)
|
|
18
|
+
read_changelog_section() {
|
|
19
|
+
local branch="$1" ver="$2" raw=""
|
|
20
|
+
if ! raw=$(git show "origin/${branch}:CHANGELOG.md" 2>/dev/null); then
|
|
21
|
+
return 0
|
|
22
|
+
fi
|
|
23
|
+
printf '%s' "$raw" | awk -v ver="$ver" '
|
|
24
|
+
BEGIN { capture = 0 }
|
|
25
|
+
/^##[[:space:]]/ {
|
|
26
|
+
if (capture) { exit }
|
|
27
|
+
header = $0
|
|
28
|
+
sub(/^##[[:space:]]+/, "", header)
|
|
29
|
+
sub(/^\[/, "", header); sub(/\].*/, "", header)
|
|
30
|
+
sub(/[[:space:]].*/, "", header)
|
|
31
|
+
sub(/[(].*/, "", header)
|
|
32
|
+
sub(/^v/, "", header)
|
|
33
|
+
if (header == ver) { capture = 1; next }
|
|
34
|
+
}
|
|
35
|
+
capture { print }
|
|
36
|
+
' | awk '
|
|
37
|
+
NF { if (!started) started = 1; out[++n] = $0; last = n; next }
|
|
38
|
+
started { out[++n] = $0 }
|
|
39
|
+
END { for (i = 1; i <= last; i++) print out[i] }
|
|
40
|
+
'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
build_pr_body() {
|
|
44
|
+
local ver="$1" changelog="$2" default_branch="$3" release_branch="$4" issue="$5"
|
|
45
|
+
printf 'Automated deploy PR opened by kody — promotes `%s` to `%s` for release **v%s**.\n\n' \
|
|
46
|
+
"$default_branch" "$release_branch" "$ver"
|
|
47
|
+
if [[ -n "$changelog" ]]; then
|
|
48
|
+
printf '<!-- kody-changelog-start -->\n## What'\''s changing in v%s\n\n%s\n<!-- kody-changelog-end -->\n\n' \
|
|
49
|
+
"$ver" "$changelog"
|
|
50
|
+
fi
|
|
51
|
+
printf 'Merge this PR to deploy v%s to `%s`.' "$ver" "$release_branch"
|
|
52
|
+
if [[ "$issue" =~ ^[0-9]+$ && "$issue" != "0" ]]; then
|
|
53
|
+
printf '\n\nTracking-Issue: #%s\n' "$issue"
|
|
54
|
+
else
|
|
55
|
+
printf '\n'
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Open or reuse the deploy PR (default_branch → release_branch).
|
|
60
|
+
# Returns the PR URL via stdout. Empty stdout = single-branch repo, no-op.
|
|
61
|
+
# Refreshes existing PR's body via gh pr edit (idempotent).
|
|
62
|
+
open_deploy_pr() {
|
|
63
|
+
local new_version="$1"
|
|
64
|
+
local issue_arg="$2"
|
|
65
|
+
local default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
|
|
66
|
+
local release_branch="${KODY_CFG_RELEASE_RELEASEBRANCH:-}"
|
|
67
|
+
|
|
68
|
+
# Single-branch repos: nothing to deploy.
|
|
69
|
+
if [[ -z "$release_branch" || "$release_branch" == "$default_branch" ]]; then
|
|
70
|
+
echo "[deploy] no releaseBranch configured (or equals defaultBranch) — skipping deploy PR" >&2
|
|
71
|
+
echo ""
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Read the section from the integration branch (where release-prepare just merged).
|
|
76
|
+
local changelog_section
|
|
77
|
+
changelog_section=$(read_changelog_section "$default_branch" "$new_version" || true)
|
|
78
|
+
if [[ -z "$changelog_section" ]]; then
|
|
79
|
+
echo "[deploy] no CHANGELOG section for v${new_version} on origin/${default_branch} — minimal body" >&2
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
local body
|
|
83
|
+
body=$(build_pr_body "$new_version" "$changelog_section" "$default_branch" "$release_branch" "$issue_arg")
|
|
84
|
+
|
|
85
|
+
# Idempotency: reuse an open PR for this branch pair if one exists.
|
|
86
|
+
local existing pr_url
|
|
87
|
+
existing=$(gh pr list --head "$default_branch" --base "$release_branch" --state open --json url --limit 1 2>/dev/null \
|
|
88
|
+
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(data[0]["url"] if data else "")' 2>/dev/null \
|
|
89
|
+
|| echo "")
|
|
90
|
+
|
|
91
|
+
if [[ -n "$existing" ]]; then
|
|
92
|
+
echo " reusing existing deploy PR: ${existing}" >&2
|
|
93
|
+
pr_url="$existing"
|
|
94
|
+
if ! printf '%s' "$body" | gh pr edit "$pr_url" --body-file - >/dev/null 2>&1; then
|
|
95
|
+
echo "[deploy] WARN: failed to refresh deploy PR body" >&2
|
|
96
|
+
fi
|
|
97
|
+
else
|
|
98
|
+
if ! pr_url=$(printf '%s' "$body" | gh pr create --head "$default_branch" --base "$release_branch" --title "deploy: ${default_branch} → ${release_branch} (v${new_version})" --body-file -); then
|
|
99
|
+
echo "[deploy] gh pr create failed" >&2
|
|
100
|
+
return 1
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
if [[ -z "$pr_url" ]]; then
|
|
105
|
+
echo "[deploy] empty PR URL after gh pr create" >&2
|
|
106
|
+
return 1
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# Persist the deploy-PR marker on the originating issue body, replacing
|
|
110
|
+
# any prepare-PR marker so the dashboard pivots to the deploy PR.
|
|
111
|
+
if [[ "$issue_arg" =~ ^[0-9]+$ && "$issue_arg" != "0" ]]; then
|
|
112
|
+
local pr_number="${pr_url##*/}"
|
|
113
|
+
if [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
|
114
|
+
local cur_body cleaned_body
|
|
115
|
+
cur_body=$(gh issue view "$issue_arg" --json body -q .body 2>/dev/null || echo "")
|
|
116
|
+
cleaned_body=$(printf '%s' "$cur_body" | sed -E '/<!-- kody-release-pr:[^>]*-->/d')
|
|
117
|
+
{
|
|
118
|
+
printf '%s' "$cleaned_body"
|
|
119
|
+
printf '\n\n<!-- kody-release-pr: #%s -->\n' "$pr_number"
|
|
120
|
+
} | gh issue edit "$issue_arg" --body-file - >/dev/null 2>&1 || \
|
|
121
|
+
echo "[deploy] WARN: failed to write kody-release-pr marker to issue #${issue_arg}" >&2
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
echo "$pr_url"
|
|
126
|
+
return 0
|
|
127
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# release/prepare.sh — function library for the prepare phase.
|
|
4
|
+
#
|
|
5
|
+
# Functions exported:
|
|
6
|
+
# read_pkg_version
|
|
7
|
+
# bump_version <cur> <patch|minor|major>
|
|
8
|
+
# write_pkg_version <file> <new>
|
|
9
|
+
# resolve_version_files -> prints \n-separated paths
|
|
10
|
+
# generate_changelog -> prints raw `subject||sha` lines
|
|
11
|
+
# format_changelog <new_version> -> reads stdin, prints markdown entry
|
|
12
|
+
# prepend_changelog <entry>
|
|
13
|
+
# remote_branch_exists <branch>
|
|
14
|
+
# find_open_pr <branch> -> prints url or empty
|
|
15
|
+
# open_prepare_pr <new_version> <issue_number> <prefer>
|
|
16
|
+
# -> echoes PR url; sets globals
|
|
17
|
+
# set_kody_release_pr_marker <issue_number> <pr_url>
|
|
18
|
+
#
|
|
19
|
+
# Side-effects: bumps version files, generates CHANGELOG.md, commits +
|
|
20
|
+
# pushes a release branch, opens the prepare PR.
|
|
21
|
+
|
|
22
|
+
# shellcheck disable=SC2148
|
|
23
|
+
|
|
24
|
+
read_pkg_version() {
|
|
25
|
+
python3 -c "import json,sys; print(json.load(open('package.json'))['version'])"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
bump_version() {
|
|
29
|
+
local cur="$1" kind="$2"
|
|
30
|
+
local core="${cur%%-*}"
|
|
31
|
+
if ! [[ "$core" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
|
32
|
+
echo "[prepare] cannot parse version '$cur'" >&2
|
|
33
|
+
return 1
|
|
34
|
+
fi
|
|
35
|
+
local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" pat="${BASH_REMATCH[3]}"
|
|
36
|
+
case "$kind" in
|
|
37
|
+
major) maj=$((maj + 1)); min=0; pat=0 ;;
|
|
38
|
+
minor) min=$((min + 1)); pat=0 ;;
|
|
39
|
+
patch|*) pat=$((pat + 1)) ;;
|
|
40
|
+
esac
|
|
41
|
+
echo "${maj}.${min}.${pat}"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
write_pkg_version() {
|
|
45
|
+
local file="$1" new="$2"
|
|
46
|
+
python3 - "$file" "$new" <<'PY'
|
|
47
|
+
import json, sys
|
|
48
|
+
path, new = sys.argv[1], sys.argv[2]
|
|
49
|
+
try:
|
|
50
|
+
with open(path) as f:
|
|
51
|
+
text = f.read()
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
print("MISSING")
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(text)
|
|
57
|
+
except Exception:
|
|
58
|
+
print("UNCHANGED")
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
if data.get("version") == new:
|
|
61
|
+
print("UNCHANGED")
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
data["version"] = new
|
|
64
|
+
with open(path, "w") as f:
|
|
65
|
+
f.write(json.dumps(data, indent=2) + "\n")
|
|
66
|
+
print("WROTE")
|
|
67
|
+
PY
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
resolve_version_files() {
|
|
71
|
+
local raw="${KODY_CFG_RELEASE_VERSIONFILES:-}"
|
|
72
|
+
if [[ -z "$raw" ]]; then
|
|
73
|
+
echo "package.json"
|
|
74
|
+
return
|
|
75
|
+
fi
|
|
76
|
+
python3 - <<PY
|
|
77
|
+
import json, os
|
|
78
|
+
raw = os.environ.get("KODY_CFG_RELEASE_VERSIONFILES", "")
|
|
79
|
+
try:
|
|
80
|
+
arr = json.loads(raw)
|
|
81
|
+
except Exception:
|
|
82
|
+
print("package.json"); raise SystemExit
|
|
83
|
+
if isinstance(arr, list) and arr:
|
|
84
|
+
for f in arr:
|
|
85
|
+
if isinstance(f, str) and f:
|
|
86
|
+
print(f)
|
|
87
|
+
else:
|
|
88
|
+
print("package.json")
|
|
89
|
+
PY
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
generate_changelog() {
|
|
93
|
+
local last_tag count
|
|
94
|
+
if ! last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
|
|
95
|
+
git fetch --tags --quiet 2>/dev/null || true
|
|
96
|
+
last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
|
|
97
|
+
fi
|
|
98
|
+
if [[ -n "$last_tag" ]]; then
|
|
99
|
+
count=$(git rev-list --count "${last_tag}..HEAD" --no-merges 2>/dev/null || echo "?")
|
|
100
|
+
echo " changelog: ${count} commits since ${last_tag}" >&2
|
|
101
|
+
git log "${last_tag}..HEAD" --pretty=format:'%s||%h' --no-merges 2>/dev/null || true
|
|
102
|
+
else
|
|
103
|
+
echo " changelog: no previous v* tag found — using last 100 commits" >&2
|
|
104
|
+
git log -n100 HEAD --pretty=format:'%s||%h' --no-merges 2>/dev/null || true
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
format_changelog() {
|
|
109
|
+
local new_version="$1"
|
|
110
|
+
local date_str
|
|
111
|
+
date_str=$(date -u +%Y-%m-%d)
|
|
112
|
+
python3 - "$new_version" "$date_str" <<'PY'
|
|
113
|
+
import sys, re
|
|
114
|
+
new_version, date_str = sys.argv[1], sys.argv[2]
|
|
115
|
+
raw = sys.stdin.read()
|
|
116
|
+
buckets = {k: [] for k in ("feat", "fix", "perf", "refactor", "docs", "chore", "other")}
|
|
117
|
+
for line in raw.splitlines():
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line or "||" not in line:
|
|
120
|
+
continue
|
|
121
|
+
subject, sha = line.split("||", 1)
|
|
122
|
+
if re.match(r"(?i)^chore:\s*release\s+v\d", subject):
|
|
123
|
+
continue
|
|
124
|
+
m = re.match(r"^(\w+)(?:\(.*?\))?\s*:\s*(.+)$", subject)
|
|
125
|
+
if m:
|
|
126
|
+
kind = m.group(1).lower()
|
|
127
|
+
msg = m.group(2)
|
|
128
|
+
else:
|
|
129
|
+
kind = "other"
|
|
130
|
+
msg = subject
|
|
131
|
+
buckets.setdefault(kind, buckets["other"]).append(f"- {msg} ({sha})")
|
|
132
|
+
labels = [
|
|
133
|
+
("feat", "Features"),
|
|
134
|
+
("fix", "Fixes"),
|
|
135
|
+
("perf", "Performance"),
|
|
136
|
+
("refactor", "Refactoring"),
|
|
137
|
+
("docs", "Docs"),
|
|
138
|
+
("chore", "Chores"),
|
|
139
|
+
("other", "Other"),
|
|
140
|
+
]
|
|
141
|
+
parts = [f"## v{new_version} — {date_str}", ""]
|
|
142
|
+
emitted = False
|
|
143
|
+
for key, label in labels:
|
|
144
|
+
items = buckets.get(key) or []
|
|
145
|
+
if not items:
|
|
146
|
+
continue
|
|
147
|
+
parts.append(f"### {label}")
|
|
148
|
+
parts.extend(items)
|
|
149
|
+
parts.append("")
|
|
150
|
+
emitted = True
|
|
151
|
+
if not emitted:
|
|
152
|
+
parts.append("_No notable commits since the last release._")
|
|
153
|
+
parts.append("")
|
|
154
|
+
sys.stdout.write("\n".join(parts))
|
|
155
|
+
PY
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
prepend_changelog() {
|
|
159
|
+
local entry="$1"
|
|
160
|
+
local header='# Changelog
|
|
161
|
+
|
|
162
|
+
All notable changes to this project will be documented in this file.
|
|
163
|
+
|
|
164
|
+
'
|
|
165
|
+
if [[ -f CHANGELOG.md ]]; then
|
|
166
|
+
if grep -qE '^#\s*Changelog\b' CHANGELOG.md; then
|
|
167
|
+
python3 - "$entry" <<'PY'
|
|
168
|
+
import sys
|
|
169
|
+
entry = sys.argv[1]
|
|
170
|
+
with open("CHANGELOG.md") as f:
|
|
171
|
+
prior = f.read()
|
|
172
|
+
idx = prior.index("\n", prior.index("# Changelog"))
|
|
173
|
+
new = prior[: idx + 1] + "\n" + entry + prior[idx + 1 :]
|
|
174
|
+
with open("CHANGELOG.md", "w") as f:
|
|
175
|
+
f.write(new)
|
|
176
|
+
PY
|
|
177
|
+
else
|
|
178
|
+
python3 - "$entry" <<PY
|
|
179
|
+
import sys
|
|
180
|
+
entry = sys.argv[1]
|
|
181
|
+
header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
|
|
182
|
+
with open("CHANGELOG.md") as f:
|
|
183
|
+
prior = f.read()
|
|
184
|
+
with open("CHANGELOG.md", "w") as f:
|
|
185
|
+
f.write(header + entry + prior)
|
|
186
|
+
PY
|
|
187
|
+
fi
|
|
188
|
+
else
|
|
189
|
+
python3 - "$entry" <<PY
|
|
190
|
+
import sys
|
|
191
|
+
entry = sys.argv[1]
|
|
192
|
+
header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
|
|
193
|
+
with open("CHANGELOG.md", "w") as f:
|
|
194
|
+
f.write(header + entry)
|
|
195
|
+
PY
|
|
196
|
+
fi
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
remote_branch_exists() {
|
|
200
|
+
local branch="$1"
|
|
201
|
+
git ls-remote --heads origin "$branch" 2>/dev/null | grep -q .
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
find_open_pr() {
|
|
205
|
+
local branch="$1"
|
|
206
|
+
gh pr list --head "$branch" --state open --json url --limit 1 2>/dev/null \
|
|
207
|
+
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(data[0]["url"] if data else "")' 2>/dev/null \
|
|
208
|
+
|| echo ""
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Returns the PREPARE PR url on stdout. Bumps version files, generates
|
|
212
|
+
# CHANGELOG, commits on a release branch, pushes, opens (or reuses) the PR.
|
|
213
|
+
# Globals it sets via export so release.sh can pass them to deploy/publish:
|
|
214
|
+
# PREPARE_NEW_VERSION
|
|
215
|
+
# PREPARE_TAG
|
|
216
|
+
# PREPARE_RELEASE_BRANCH
|
|
217
|
+
# PREPARE_CHANGELOG_ENTRY
|
|
218
|
+
open_prepare_pr() {
|
|
219
|
+
local new_version="$1"
|
|
220
|
+
local issue_arg="$2"
|
|
221
|
+
local prefer="$3"
|
|
222
|
+
local default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
|
|
223
|
+
|
|
224
|
+
local tag="v${new_version}"
|
|
225
|
+
local release_branch="release/${tag}"
|
|
226
|
+
|
|
227
|
+
export PREPARE_NEW_VERSION="$new_version"
|
|
228
|
+
export PREPARE_TAG="$tag"
|
|
229
|
+
export PREPARE_RELEASE_BRANCH="$release_branch"
|
|
230
|
+
|
|
231
|
+
# Branch-collision gate.
|
|
232
|
+
local collides=false
|
|
233
|
+
if remote_branch_exists "$release_branch"; then
|
|
234
|
+
collides=true
|
|
235
|
+
case "$prefer" in
|
|
236
|
+
theirs)
|
|
237
|
+
local existing
|
|
238
|
+
existing=$(find_open_pr "$release_branch")
|
|
239
|
+
if [[ -n "$existing" ]]; then
|
|
240
|
+
echo " reusing existing PR (--prefer theirs): ${existing}" >&2
|
|
241
|
+
echo "$existing"
|
|
242
|
+
return 0
|
|
243
|
+
fi
|
|
244
|
+
echo "[prepare] --prefer theirs: ${release_branch} exists but no open PR" >&2
|
|
245
|
+
return 1
|
|
246
|
+
;;
|
|
247
|
+
ours)
|
|
248
|
+
echo " branch ${release_branch} exists — will force-push (--prefer ours)" >&2
|
|
249
|
+
;;
|
|
250
|
+
*)
|
|
251
|
+
echo "[prepare] branch ${release_branch} already exists. Use --prefer ours/theirs." >&2
|
|
252
|
+
return 1
|
|
253
|
+
;;
|
|
254
|
+
esac
|
|
255
|
+
fi
|
|
256
|
+
|
|
257
|
+
# Bump version files.
|
|
258
|
+
local files
|
|
259
|
+
mapfile -t files < <(resolve_version_files)
|
|
260
|
+
local touched=()
|
|
261
|
+
for f in "${files[@]}"; do
|
|
262
|
+
local res
|
|
263
|
+
res=$(write_pkg_version "$f" "$new_version")
|
|
264
|
+
if [[ "$res" == "WROTE" ]]; then
|
|
265
|
+
touched+=("$f")
|
|
266
|
+
fi
|
|
267
|
+
done
|
|
268
|
+
if [[ ${#touched[@]} -eq 0 ]]; then
|
|
269
|
+
echo "[prepare] no version strings updated (files: ${files[*]})" >&2
|
|
270
|
+
return 1
|
|
271
|
+
fi
|
|
272
|
+
echo " wrote ${touched[*]}" >&2
|
|
273
|
+
|
|
274
|
+
# Changelog.
|
|
275
|
+
local raw_log entry
|
|
276
|
+
raw_log=$(generate_changelog) || raw_log=""
|
|
277
|
+
entry=$(printf '%s' "$raw_log" | format_changelog "$new_version")
|
|
278
|
+
prepend_changelog "$entry"
|
|
279
|
+
echo " wrote CHANGELOG.md" >&2
|
|
280
|
+
export PREPARE_CHANGELOG_ENTRY="$entry"
|
|
281
|
+
|
|
282
|
+
# Commit + push.
|
|
283
|
+
export HUSKY=0 SKIP_HOOKS=1
|
|
284
|
+
git checkout -b "$release_branch"
|
|
285
|
+
for f in "${touched[@]}" CHANGELOG.md; do
|
|
286
|
+
git add -- "$f"
|
|
287
|
+
done
|
|
288
|
+
git -c commit.gpgsign=false commit -m "chore: release ${tag}"
|
|
289
|
+
if [[ "$collides" == "true" && "$prefer" == "ours" ]]; then
|
|
290
|
+
git push -u --force-with-lease origin "$release_branch"
|
|
291
|
+
else
|
|
292
|
+
git push -u origin "$release_branch"
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
# Open PR.
|
|
296
|
+
local pr_url=""
|
|
297
|
+
if [[ "$collides" == "true" && "$prefer" == "ours" ]]; then
|
|
298
|
+
pr_url=$(find_open_pr "$release_branch")
|
|
299
|
+
fi
|
|
300
|
+
|
|
301
|
+
if [[ -z "$pr_url" ]]; then
|
|
302
|
+
local body_max=60000 body_entry
|
|
303
|
+
if [[ ${#entry} -gt $body_max ]]; then
|
|
304
|
+
body_entry="${entry:0:$body_max}
|
|
305
|
+
|
|
306
|
+
_… truncated; see CHANGELOG.md_"
|
|
307
|
+
else
|
|
308
|
+
body_entry="$entry"
|
|
309
|
+
fi
|
|
310
|
+
local tracking_line=""
|
|
311
|
+
if [[ "$issue_arg" =~ ^[0-9]+$ && "$issue_arg" != "0" ]]; then
|
|
312
|
+
tracking_line=$'\n\nTracking-Issue: #'"${issue_arg}"
|
|
313
|
+
fi
|
|
314
|
+
local body
|
|
315
|
+
body=$'Automated release PR opened by kody.\n\n'"$body_entry"$'\n\nThe release flow will merge this into `'"${default_branch}"$'` and continue to publish + deploy.'"${tracking_line}"
|
|
316
|
+
pr_url=$(printf '%s' "$body" | gh pr create --head "$release_branch" --base "$default_branch" --title "chore: release ${tag}" --body-file -)
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
if [[ -z "$pr_url" ]]; then
|
|
320
|
+
echo "[prepare] gh pr create returned empty URL" >&2
|
|
321
|
+
return 1
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
echo "$pr_url"
|
|
325
|
+
return 0
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
set_kody_release_pr_marker() {
|
|
329
|
+
local issue_arg="$1" pr_url="$2"
|
|
330
|
+
if [[ ! "$issue_arg" =~ ^[0-9]+$ ]] || [[ "$issue_arg" == "0" ]]; then
|
|
331
|
+
return 0
|
|
332
|
+
fi
|
|
333
|
+
local pr_number="${pr_url##*/}"
|
|
334
|
+
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
|
|
335
|
+
return 0
|
|
336
|
+
fi
|
|
337
|
+
local cur_body cleaned_body
|
|
338
|
+
cur_body=$(gh issue view "$issue_arg" --json body -q .body 2>/dev/null || echo "")
|
|
339
|
+
cleaned_body=$(printf '%s' "$cur_body" | sed -E '/<!-- kody-release-pr:[^>]*-->/d')
|
|
340
|
+
{
|
|
341
|
+
printf '%s' "$cleaned_body"
|
|
342
|
+
printf '\n\n<!-- kody-release-pr: #%s -->\n' "$pr_number"
|
|
343
|
+
} | gh issue edit "$issue_arg" --body-file - >/dev/null 2>&1 || \
|
|
344
|
+
echo "[prepare] WARN: failed to write kody-release-pr marker to issue #${issue_arg}" >&2
|
|
345
|
+
}
|
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "release",
|
|
3
|
-
"role": "
|
|
4
|
-
"
|
|
3
|
+
"role": "utility",
|
|
4
|
+
"phase": "shipped",
|
|
5
|
+
"describe": "Single-job release flow: prepare → wait CI → merge → publish → deploy → notify. No orchestrator chain — runs end-to-end inside one workflow run.",
|
|
5
6
|
"inputs": [
|
|
6
7
|
{
|
|
7
8
|
"name": "issue",
|
|
8
9
|
"flag": "--issue",
|
|
9
10
|
"type": "int",
|
|
10
11
|
"required": true,
|
|
11
|
-
"describe": "GitHub issue number
|
|
12
|
+
"describe": "GitHub issue number that triggered the release."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "bump",
|
|
16
|
+
"flag": "--bump",
|
|
17
|
+
"type": "enum",
|
|
18
|
+
"values": ["patch", "minor", "major"],
|
|
19
|
+
"required": false,
|
|
20
|
+
"describe": "Version bump increment. Default patch."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "dry-run",
|
|
24
|
+
"flag": "--dry-run",
|
|
25
|
+
"type": "bool",
|
|
26
|
+
"required": false,
|
|
27
|
+
"describe": "Print plan without writing files, committing, or opening a PR."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "prefer",
|
|
31
|
+
"flag": "--prefer",
|
|
32
|
+
"type": "enum",
|
|
33
|
+
"values": ["ours", "theirs"],
|
|
34
|
+
"required": false,
|
|
35
|
+
"describe": "On release/vX.Y.Z branch collision: 'ours' force-pushes; 'theirs' reuses the existing PR."
|
|
12
36
|
}
|
|
13
37
|
],
|
|
14
38
|
"claudeCode": {
|
|
@@ -38,54 +62,34 @@
|
|
|
38
62
|
"description": "kody flow: release"
|
|
39
63
|
}
|
|
40
64
|
},
|
|
41
|
-
{
|
|
42
|
-
"script": "setLifecycleLabel",
|
|
43
|
-
"with": {
|
|
44
|
-
"label": "kody:orchestrating",
|
|
45
|
-
"color": "1d76db",
|
|
46
|
-
"description": "kody: orchestrating a multi-stage flow"
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
65
|
{ "script": "loadIssueContext" },
|
|
50
66
|
{ "script": "loadTaskState" },
|
|
67
|
+
{ "shell": "release.sh", "timeoutSec": 5400 },
|
|
51
68
|
{ "script": "skipAgent" }
|
|
52
69
|
],
|
|
53
70
|
"postflight": [
|
|
54
|
-
{ "script": "
|
|
55
|
-
|
|
56
|
-
{
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"runWhen": { "data.
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"runWhen": { "data.action.type": "
|
|
75
|
-
|
|
76
|
-
{ "script": "finishFlow",
|
|
77
|
-
"with": { "reason": "release-completed", "label": "kody:done", "color": "0e8a16", "description": "kody: release complete" },
|
|
78
|
-
"runWhen": { "data.action.type": "CI_PASSED" } },
|
|
79
|
-
|
|
80
|
-
{ "script": "finishFlow",
|
|
81
|
-
"with": { "reason": "release-failed", "label": "kody:failed", "color": "e11d21", "description": "kody: release flow failed" },
|
|
82
|
-
"runWhen": { "data.action.type": ["CI_GIVEUP", "CI_TIMEOUT"] } },
|
|
83
|
-
|
|
84
|
-
{ "script": "finishFlow",
|
|
85
|
-
"with": { "reason": "release-failed", "label": "kody:failed", "color": "e11d21", "description": "kody: release flow failed" },
|
|
86
|
-
"runWhen": { "data.taskState.core.lastOutcome.type": ["RELEASE_PREPARE_FAILED", "RELEASE_MERGE_FAILED", "RELEASE_PUBLISH_FAILED", "RELEASE_DEPLOY_FAILED", "FIX_CI_FAILED"] } },
|
|
87
|
-
|
|
88
|
-
{ "script": "persistFlowState" }
|
|
71
|
+
{ "script": "recordOutcome" },
|
|
72
|
+
{ "script": "saveTaskState" },
|
|
73
|
+
{
|
|
74
|
+
"script": "finishFlow",
|
|
75
|
+
"with": {
|
|
76
|
+
"reason": "release-completed",
|
|
77
|
+
"label": "kody:done",
|
|
78
|
+
"color": "0e8a16",
|
|
79
|
+
"description": "kody: release complete"
|
|
80
|
+
},
|
|
81
|
+
"runWhen": { "data.action.type": "RELEASE_COMPLETED" }
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"script": "finishFlow",
|
|
85
|
+
"with": {
|
|
86
|
+
"reason": "release-failed",
|
|
87
|
+
"label": "kody:failed",
|
|
88
|
+
"color": "e11d21",
|
|
89
|
+
"description": "kody: release flow failed"
|
|
90
|
+
},
|
|
91
|
+
"runWhen": { "data.action.type": "RELEASE_FAILED" }
|
|
92
|
+
}
|
|
89
93
|
]
|
|
90
94
|
}
|
|
91
95
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# release/publish.sh — function library for the publish phase.
|
|
4
|
+
#
|
|
5
|
+
# Functions:
|
|
6
|
+
# tag_and_publish <new_version> -> creates tag locally, pushes, runs publishCommand
|
|
7
|
+
# create_gh_release <tag> -> echoes release URL or empty
|
|
8
|
+
|
|
9
|
+
# shellcheck disable=SC2148
|
|
10
|
+
|
|
11
|
+
tag_and_publish() {
|
|
12
|
+
local new_version="$1"
|
|
13
|
+
local publish_cmd="${KODY_CFG_RELEASE_PUBLISHCOMMAND:-}"
|
|
14
|
+
local timeout_ms="${KODY_CFG_RELEASE_TIMEOUTMS:-600000}"
|
|
15
|
+
local timeout_s=$((timeout_ms / 1000))
|
|
16
|
+
local tag="v${new_version}"
|
|
17
|
+
|
|
18
|
+
# Refuse if the tag already exists locally (left over from a prior failed run).
|
|
19
|
+
if git rev-parse --verify "$tag" >/dev/null 2>&1; then
|
|
20
|
+
echo "[publish] tag ${tag} already exists locally" >&2
|
|
21
|
+
return 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
git tag -a "$tag" -m "Release ${tag}"
|
|
25
|
+
git push origin "$tag"
|
|
26
|
+
|
|
27
|
+
# publishCommand (optional). Failure here is recorded but does not abort —
|
|
28
|
+
# we still want the GH release entry so the tag is discoverable.
|
|
29
|
+
local publish_status="skipped"
|
|
30
|
+
if [[ -n "$publish_cmd" ]]; then
|
|
31
|
+
local cmd="${publish_cmd//\$VERSION/$new_version}"
|
|
32
|
+
echo " publish: ${cmd}" >&2
|
|
33
|
+
if timeout "${timeout_s}" bash -c "$cmd"; then
|
|
34
|
+
publish_status="ok"
|
|
35
|
+
else
|
|
36
|
+
publish_status="failed"
|
|
37
|
+
echo "[publish] publishCommand failed (continuing to create GH release)" >&2
|
|
38
|
+
fi
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
echo "$publish_status"
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
create_gh_release() {
|
|
46
|
+
local tag="$1"
|
|
47
|
+
local draft="${KODY_CFG_RELEASE_DRAFTRELEASE:-false}"
|
|
48
|
+
local draft_flag=""
|
|
49
|
+
[[ "$draft" == "true" ]] && draft_flag="--draft"
|
|
50
|
+
|
|
51
|
+
local release_url=""
|
|
52
|
+
if release_url=$(gh release create "$tag" --title "$tag" --notes "Release ${tag} — automated by kody." $draft_flag 2>&1); then
|
|
53
|
+
echo "$release_url"
|
|
54
|
+
return 0
|
|
55
|
+
else
|
|
56
|
+
echo "[publish] gh release create failed: $release_url" >&2
|
|
57
|
+
echo ""
|
|
58
|
+
return 1
|
|
59
|
+
fi
|
|
60
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# release/release.sh — single-job release driver.
|
|
4
|
+
#
|
|
5
|
+
# Replaces the orchestrator + 3-executable chain with one linear bash flow:
|
|
6
|
+
# prepare → wait CI → merge → publish → deploy → notify
|
|
7
|
+
#
|
|
8
|
+
# Inputs (env, set by the executor):
|
|
9
|
+
# KODY_ARG_ISSUE triggering issue number (required)
|
|
10
|
+
# KODY_ARG_BUMP patch|minor|major (default: patch)
|
|
11
|
+
# KODY_ARG_DRY_RUN true|false
|
|
12
|
+
# KODY_ARG_PREFER ours|theirs (optional; for branch collision)
|
|
13
|
+
#
|
|
14
|
+
# Config (env, flattened from kody.config.json):
|
|
15
|
+
# KODY_CFG_GIT_DEFAULTBRANCH e.g. dev
|
|
16
|
+
# KODY_CFG_RELEASE_RELEASEBRANCH e.g. main (unset → deploy is no-op)
|
|
17
|
+
# KODY_CFG_RELEASE_VERSIONFILES JSON array
|
|
18
|
+
# KODY_CFG_RELEASE_PUBLISHCOMMAND optional; $VERSION substituted
|
|
19
|
+
# KODY_CFG_RELEASE_NOTIFYCOMMAND optional; $VERSION + $DEPLOY_PR_URL substituted
|
|
20
|
+
# KODY_CFG_RELEASE_DRAFTRELEASE "true" → create as draft
|
|
21
|
+
# KODY_CFG_RELEASE_TIMEOUTMS per-command timeout in ms
|
|
22
|
+
#
|
|
23
|
+
# Stdout signals:
|
|
24
|
+
# KODY_PR_URL=<deploy PR url>
|
|
25
|
+
# KODY_REASON=<text>
|
|
26
|
+
# KODY_SKIP_AGENT=true
|
|
27
|
+
# RELEASE_COMPLETED=true | RELEASE_FAILED=true (consumed by recordOutcome → finishFlow)
|
|
28
|
+
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
HERE="$(dirname "$0")"
|
|
32
|
+
# shellcheck source=prepare.sh
|
|
33
|
+
source "$HERE/prepare.sh"
|
|
34
|
+
# shellcheck source=wait.sh
|
|
35
|
+
source "$HERE/wait.sh"
|
|
36
|
+
# shellcheck source=publish.sh
|
|
37
|
+
source "$HERE/publish.sh"
|
|
38
|
+
# shellcheck source=deploy.sh
|
|
39
|
+
source "$HERE/deploy.sh"
|
|
40
|
+
|
|
41
|
+
issue="${KODY_ARG_ISSUE:?required}"
|
|
42
|
+
bump="${KODY_ARG_BUMP:-patch}"
|
|
43
|
+
dry_run="${KODY_ARG_DRY_RUN:-false}"
|
|
44
|
+
prefer="${KODY_ARG_PREFER:-}"
|
|
45
|
+
|
|
46
|
+
default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
|
|
47
|
+
notify_cmd="${KODY_CFG_RELEASE_NOTIFYCOMMAND:-}"
|
|
48
|
+
notify_timeout_s=$(( ${KODY_CFG_RELEASE_TIMEOUTMS:-600000} / 1000 ))
|
|
49
|
+
|
|
50
|
+
# Tracks where we were when an error fired, for clearer failure messages.
|
|
51
|
+
current_step="init"
|
|
52
|
+
|
|
53
|
+
on_error() {
|
|
54
|
+
local rc=$?
|
|
55
|
+
echo "[release] FAILED during step '${current_step}' (exit ${rc})" >&2
|
|
56
|
+
echo "KODY_REASON=release failed during ${current_step}"
|
|
57
|
+
echo "RELEASE_FAILED=true"
|
|
58
|
+
echo "KODY_SKIP_AGENT=true"
|
|
59
|
+
exit "$rc"
|
|
60
|
+
}
|
|
61
|
+
trap on_error ERR
|
|
62
|
+
|
|
63
|
+
if [[ ! -f package.json ]]; then
|
|
64
|
+
echo "[release] package.json not found in $(pwd)" >&2
|
|
65
|
+
echo "KODY_REASON=release: package.json not found"
|
|
66
|
+
echo "RELEASE_FAILED=true"
|
|
67
|
+
echo "KODY_SKIP_AGENT=true"
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# ── 1. Prepare ────────────────────────────────────────────────────────────
|
|
72
|
+
current_step="prepare"
|
|
73
|
+
old_version=$(read_pkg_version)
|
|
74
|
+
new_version=$(bump_version "$old_version" "$bump")
|
|
75
|
+
tag="v${new_version}"
|
|
76
|
+
echo "→ release: issue=#${issue} bump=${bump} ${old_version} → ${new_version}"
|
|
77
|
+
|
|
78
|
+
if [[ "$dry_run" == "true" ]]; then
|
|
79
|
+
echo "RELEASE_PLAN=bump=${new_version} tag=${tag}"
|
|
80
|
+
echo "KODY_REASON=dry-run — would bump to ${new_version}${prefer:+ (--prefer ${prefer})}"
|
|
81
|
+
echo "RELEASE_COMPLETED=true"
|
|
82
|
+
echo "KODY_SKIP_AGENT=true"
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
prep_pr_url=$(open_prepare_pr "$new_version" "$issue" "$prefer")
|
|
87
|
+
if [[ -z "$prep_pr_url" ]]; then
|
|
88
|
+
echo "[release] prepare returned no PR URL" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
echo "✓ prepare: ${prep_pr_url}"
|
|
92
|
+
set_kody_release_pr_marker "$issue" "$prep_pr_url"
|
|
93
|
+
|
|
94
|
+
# ── 2. Wait for prepare PR CI ─────────────────────────────────────────────
|
|
95
|
+
current_step="wait_prepare_ci"
|
|
96
|
+
prep_pr_num="${prep_pr_url##*/}"
|
|
97
|
+
wait_for_ci "$prep_pr_num" 60 || {
|
|
98
|
+
echo "[release] CI failed/timeout on prepare PR #${prep_pr_num}" >&2
|
|
99
|
+
exit 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# ── 3. Merge prepare PR ───────────────────────────────────────────────────
|
|
103
|
+
current_step="merge"
|
|
104
|
+
if gh pr merge "$prep_pr_num" --merge --admin 2>&1; then
|
|
105
|
+
echo "✓ merged: PR #${prep_pr_num}"
|
|
106
|
+
elif gh pr merge "$prep_pr_num" --merge 2>&1 | grep -qi "already merged"; then
|
|
107
|
+
echo " (already merged)"
|
|
108
|
+
else
|
|
109
|
+
echo "[release] gh pr merge failed for PR #${prep_pr_num}" >&2
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# ── 4. Publish (tag + GH release) ─────────────────────────────────────────
|
|
114
|
+
current_step="publish"
|
|
115
|
+
git fetch origin "$default_branch" --tags
|
|
116
|
+
git checkout "$default_branch"
|
|
117
|
+
git reset --hard "origin/$default_branch"
|
|
118
|
+
|
|
119
|
+
# Sanity: confirm the bump landed on the integration branch.
|
|
120
|
+
landed_version=$(read_pkg_version)
|
|
121
|
+
if [[ "$landed_version" != "$new_version" ]]; then
|
|
122
|
+
echo "[release] WARN: package.json on ${default_branch} is ${landed_version}, expected ${new_version} after merge" >&2
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
publish_status=$(tag_and_publish "$new_version")
|
|
126
|
+
release_url=$(create_gh_release "$tag" || echo "")
|
|
127
|
+
echo "✓ publish: tag=${tag} status=${publish_status} release_url=${release_url:-<none>}"
|
|
128
|
+
|
|
129
|
+
if [[ "$publish_status" == "failed" ]]; then
|
|
130
|
+
echo "[release] publishCommand failed but tag + GH release exist" >&2
|
|
131
|
+
echo "KODY_REASON=tag + GH release created, but publishCommand failed"
|
|
132
|
+
echo "RELEASE_FAILED=true"
|
|
133
|
+
echo "KODY_SKIP_AGENT=true"
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# ── 5. Deploy PR (default → release branch) ───────────────────────────────
|
|
138
|
+
current_step="deploy"
|
|
139
|
+
deploy_pr_url=$(open_deploy_pr "$new_version" "$issue" || echo "")
|
|
140
|
+
if [[ -z "$deploy_pr_url" ]]; then
|
|
141
|
+
echo " (deploy: no-op — single-branch repo)"
|
|
142
|
+
else
|
|
143
|
+
echo "✓ deploy: ${deploy_pr_url}"
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
# ── 6. Notify ─────────────────────────────────────────────────────────────
|
|
147
|
+
current_step="notify"
|
|
148
|
+
notify_status="skipped"
|
|
149
|
+
if [[ -n "$notify_cmd" ]]; then
|
|
150
|
+
cmd="${notify_cmd//\$VERSION/$new_version}"
|
|
151
|
+
cmd="${cmd//\$DEPLOY_PR_URL/${deploy_pr_url:-}}"
|
|
152
|
+
echo " notify: ${cmd}"
|
|
153
|
+
if timeout "$notify_timeout_s" bash -c "$cmd"; then
|
|
154
|
+
notify_status="ok"
|
|
155
|
+
else
|
|
156
|
+
notify_status="failed"
|
|
157
|
+
echo "[release] notifyCommand failed (non-fatal)" >&2
|
|
158
|
+
fi
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
# ── 7. Done ───────────────────────────────────────────────────────────────
|
|
162
|
+
current_step="done"
|
|
163
|
+
[[ -n "$deploy_pr_url" ]] && echo "KODY_PR_URL=${deploy_pr_url}"
|
|
164
|
+
echo "RELEASE_TAG=${tag}"
|
|
165
|
+
[[ -n "$release_url" ]] && echo "RELEASE_URL=${release_url}"
|
|
166
|
+
[[ -n "$deploy_pr_url" ]] && echo "RELEASE_DEPLOY_PR=${deploy_pr_url}"
|
|
167
|
+
echo "KODY_REASON=release v${new_version} complete (notify=${notify_status})"
|
|
168
|
+
echo "RELEASE_COMPLETED=true"
|
|
169
|
+
echo "KODY_SKIP_AGENT=true"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# release/wait.sh — wait_for_ci function: poll a PR's check rollup
|
|
4
|
+
# until all non-skipped checks pass, or timeout.
|
|
5
|
+
#
|
|
6
|
+
# Function: wait_for_ci <pr_number> <timeout_minutes> [poll_seconds] [initial_wait]
|
|
7
|
+
# Returns 0 on CI passed, 1 on CI failed/timeout.
|
|
8
|
+
#
|
|
9
|
+
# Special-case: if a PR has zero registered checks (no CI configured),
|
|
10
|
+
# treats that as PASSED after a short stabilization window — so a Tester
|
|
11
|
+
# repo without checks doesn't loop forever.
|
|
12
|
+
|
|
13
|
+
# shellcheck disable=SC2148
|
|
14
|
+
|
|
15
|
+
wait_for_ci() {
|
|
16
|
+
local pr_number="$1"
|
|
17
|
+
local timeout_minutes="${2:-60}"
|
|
18
|
+
local poll_seconds="${3:-30}"
|
|
19
|
+
local initial_wait="${4:-15}"
|
|
20
|
+
|
|
21
|
+
if [[ -z "$pr_number" || ! "$pr_number" =~ ^[0-9]+$ ]]; then
|
|
22
|
+
echo "[wait_for_ci] invalid pr_number: '$pr_number'" >&2
|
|
23
|
+
return 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
local deadline=$(( $(date +%s) + timeout_minutes * 60 ))
|
|
27
|
+
echo "→ wait_for_ci: PR #${pr_number}, timeout=${timeout_minutes}m"
|
|
28
|
+
|
|
29
|
+
sleep "$initial_wait"
|
|
30
|
+
|
|
31
|
+
# Track consecutive "no checks" results so we don't bail prematurely
|
|
32
|
+
# on a PR that's just slow to register checks.
|
|
33
|
+
local empty_count=0
|
|
34
|
+
local empty_threshold=4 # ~2 minutes (4 * poll_seconds=30s) before treating as no-CI
|
|
35
|
+
|
|
36
|
+
while (( $(date +%s) < deadline )); do
|
|
37
|
+
local raw
|
|
38
|
+
if ! raw=$(gh pr checks "$pr_number" --json state 2>/dev/null); then
|
|
39
|
+
# gh exits non-zero when there are no checks — treat as no-CI.
|
|
40
|
+
empty_count=$((empty_count + 1))
|
|
41
|
+
echo " [wait_for_ci] gh pr checks returned non-zero (count=${empty_count})"
|
|
42
|
+
if (( empty_count >= empty_threshold )); then
|
|
43
|
+
echo "→ wait_for_ci: PR #${pr_number} has no CI checks configured — treating as passed"
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
sleep "$poll_seconds"
|
|
47
|
+
continue
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Empty array? Same path.
|
|
51
|
+
local total
|
|
52
|
+
total=$(printf '%s' "$raw" | jq 'length' 2>/dev/null || echo "0")
|
|
53
|
+
if [[ "$total" == "0" ]]; then
|
|
54
|
+
empty_count=$((empty_count + 1))
|
|
55
|
+
echo " [wait_for_ci] no checks registered yet (count=${empty_count})"
|
|
56
|
+
if (( empty_count >= empty_threshold )); then
|
|
57
|
+
echo "→ wait_for_ci: PR #${pr_number} has no CI checks configured — treating as passed"
|
|
58
|
+
return 0
|
|
59
|
+
fi
|
|
60
|
+
sleep "$poll_seconds"
|
|
61
|
+
continue
|
|
62
|
+
fi
|
|
63
|
+
empty_count=0
|
|
64
|
+
|
|
65
|
+
# Tally states via jq.
|
|
66
|
+
local pending failed passed failed_names
|
|
67
|
+
pending=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("PENDING","IN_PROGRESS","QUEUED",""))] | length')
|
|
68
|
+
failed=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE"))] | length')
|
|
69
|
+
passed=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("SUCCESS","SKIPPED","NEUTRAL"))] | length')
|
|
70
|
+
failed_names=$(printf '%s' "$raw" | jq -r '[.[] | select((.state // "") | IN("FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE")) | .name] | join(",")')
|
|
71
|
+
|
|
72
|
+
echo " [wait_for_ci] pending=${pending} passed=${passed} failed=${failed} (total=${total})"
|
|
73
|
+
|
|
74
|
+
if [[ "$failed" -gt 0 ]]; then
|
|
75
|
+
echo "[wait_for_ci] CI failed on PR #${pr_number}: ${failed_names}" >&2
|
|
76
|
+
return 1
|
|
77
|
+
fi
|
|
78
|
+
if [[ "$pending" -eq 0 && "$passed" -gt 0 ]]; then
|
|
79
|
+
echo "→ wait_for_ci: all checks passed (${passed}) on PR #${pr_number}"
|
|
80
|
+
return 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
sleep "$poll_seconds"
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
echo "[wait_for_ci] timeout after ${timeout_minutes}m on PR #${pr_number}" >&2
|
|
87
|
+
return 1
|
|
88
|
+
}
|
|
@@ -21,14 +21,18 @@ export interface Profile {
|
|
|
21
21
|
/**
|
|
22
22
|
* Semantic role — what this executable IS, not when it runs.
|
|
23
23
|
* - primitive: single-step agent executor (flow → agent → verify → commit → PR).
|
|
24
|
-
* - orchestrator: no-agent, drives primitives via a postflight transition table
|
|
24
|
+
* - orchestrator: no-agent, drives primitives via a postflight transition table
|
|
25
|
+
* (comment-based, one GHA run per step).
|
|
26
|
+
* - container: no-agent, runs declared `children` sequentially in-process
|
|
27
|
+
* (one GHA run for the whole flow). Routing is done by per-child
|
|
28
|
+
* `next` maps over action types — no @kody comments dispatched.
|
|
25
29
|
* - watch: scheduled observer that inspects repo state and may trigger other executables.
|
|
26
30
|
* - utility: no-agent, one-off administrative work (scaffolding, release, etc.).
|
|
27
31
|
*
|
|
28
32
|
* Roles enforce shape at profile-load time and let help/dispatch treat
|
|
29
33
|
* executables differently by category.
|
|
30
34
|
*/
|
|
31
|
-
role: "primitive" | "orchestrator" | "watch" | "utility"
|
|
35
|
+
role: "primitive" | "orchestrator" | "container" | "watch" | "utility"
|
|
32
36
|
/**
|
|
33
37
|
* Execution model — orthogonal to `role`.
|
|
34
38
|
* `oneshot` (default): single invocation on demand.
|
|
@@ -65,10 +69,44 @@ export interface Profile {
|
|
|
65
69
|
* Artifact entry into the task-state comment's `artifacts` map.
|
|
66
70
|
*/
|
|
67
71
|
outputArtifacts: OutputArtifactSpec[]
|
|
72
|
+
/**
|
|
73
|
+
* Container children — required when role === "container", forbidden otherwise.
|
|
74
|
+
* Defines the in-process step sequence and routing map. See ContainerChild.
|
|
75
|
+
*/
|
|
76
|
+
children?: ContainerChild[]
|
|
68
77
|
/** Absolute directory the profile was loaded from. Used to resolve prompt.md. */
|
|
69
78
|
dir: string
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
/**
|
|
82
|
+
* One step in a container's child sequence.
|
|
83
|
+
*
|
|
84
|
+
* The container executor runs the first child, reads the resulting action
|
|
85
|
+
* type from `state.core.lastOutcome`, then looks it up in `next`:
|
|
86
|
+
* - exact match → either the name of another child in this container, or
|
|
87
|
+
* the literal "done" / "abort"
|
|
88
|
+
* - "*" wildcard → fallback when no exact match
|
|
89
|
+
* - no match → container aborts
|
|
90
|
+
*/
|
|
91
|
+
export interface ContainerChild {
|
|
92
|
+
/** Name of the executable to invoke (must resolve via the registry). */
|
|
93
|
+
exec: string
|
|
94
|
+
/**
|
|
95
|
+
* Where to source the target identifier from when invoking this child.
|
|
96
|
+
* - "issue": pass --issue <ctx.args.issue>
|
|
97
|
+
* - "pr": parse PR number from state.core.prUrl, pass --pr <N>.
|
|
98
|
+
* If state.core.prUrl is not set, the container aborts with
|
|
99
|
+
* an AGENT_NOT_RUN action.
|
|
100
|
+
*/
|
|
101
|
+
target: "issue" | "pr"
|
|
102
|
+
/**
|
|
103
|
+
* Map from action.type → next step. Each value must be the name of another
|
|
104
|
+
* child in this container, "done" (exit 0), or "abort" (exit 1). Lookup is
|
|
105
|
+
* exact-match first, then "*" as a wildcard fallback.
|
|
106
|
+
*/
|
|
107
|
+
next: Record<string, string>
|
|
108
|
+
}
|
|
109
|
+
|
|
72
110
|
export interface InputArtifactSpec {
|
|
73
111
|
/** Artifact name (the key in state.artifacts). */
|
|
74
112
|
name: string
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kody-ade/kody-engine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.70-beta.2",
|
|
4
4
|
"description": "kody — autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|