@elmundi/ship-cli 0.8.1 → 0.12.0
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 +651 -25
- package/bin/shipctl.mjs +168 -0
- package/lib/adapters/_fs.mjs +165 -0
- package/lib/adapters/agents/index.mjs +26 -0
- package/lib/adapters/ci/azure-pipelines.mjs +23 -0
- package/lib/adapters/ci/buildkite.mjs +24 -0
- package/lib/adapters/ci/circleci.mjs +23 -0
- package/lib/adapters/ci/gh-actions.mjs +29 -0
- package/lib/adapters/ci/gitlab-ci.mjs +23 -0
- package/lib/adapters/ci/jenkins.mjs +23 -0
- package/lib/adapters/ci/manual.mjs +18 -0
- package/lib/adapters/index.mjs +122 -0
- package/lib/adapters/language/dart.mjs +23 -0
- package/lib/adapters/language/go.mjs +23 -0
- package/lib/adapters/language/java.mjs +27 -0
- package/lib/adapters/language/js.mjs +32 -0
- package/lib/adapters/language/kotlin.mjs +48 -0
- package/lib/adapters/language/py.mjs +34 -0
- package/lib/adapters/language/rust.mjs +23 -0
- package/lib/adapters/language/swift.mjs +37 -0
- package/lib/adapters/language/ts.mjs +35 -0
- package/lib/adapters/trackers/azure-boards.mjs +49 -0
- package/lib/adapters/trackers/clickup.mjs +43 -0
- package/lib/adapters/trackers/github-issues.mjs +52 -0
- package/lib/adapters/trackers/jira.mjs +72 -0
- package/lib/adapters/trackers/linear.mjs +62 -0
- package/lib/adapters/trackers/none.mjs +18 -0
- package/lib/adapters/trackers/spreadsheet.mjs +28 -0
- package/lib/artifacts/fs-index.mjs +230 -0
- package/lib/bootstrap/render.mjs +422 -0
- package/lib/cache/store.mjs +422 -0
- package/lib/commands/bootstrap.mjs +4 -0
- package/lib/commands/callback.mjs +742 -0
- package/lib/commands/config.mjs +257 -0
- package/lib/commands/docs.mjs +4 -4
- package/lib/commands/doctor.mjs +583 -0
- package/lib/commands/feedback.mjs +355 -0
- package/lib/commands/help.mjs +159 -24
- package/lib/commands/init.mjs +830 -158
- package/lib/commands/kickoff.mjs +192 -0
- package/lib/commands/knowledge.mjs +562 -0
- package/lib/commands/lanes.mjs +527 -0
- package/lib/commands/manifest-catalog.mjs +106 -42
- package/lib/commands/migrate.mjs +204 -0
- package/lib/commands/new.mjs +452 -0
- package/lib/commands/patterns.mjs +14 -48
- package/lib/commands/run.mjs +857 -0
- package/lib/commands/search.mjs +2 -2
- package/lib/commands/sync.mjs +824 -0
- package/lib/commands/telemetry.mjs +390 -0
- package/lib/commands/trigger.mjs +196 -0
- package/lib/commands/verify.mjs +187 -0
- package/lib/config/io.mjs +232 -0
- package/lib/config/migrate.mjs +223 -0
- package/lib/config/schema.mjs +901 -0
- package/lib/detect.mjs +162 -19
- package/lib/feedback/drafts.mjs +129 -0
- package/lib/find-ship-root.mjs +16 -10
- package/lib/http.mjs +237 -11
- package/lib/state/idempotency.mjs +183 -0
- package/lib/state/lockfile.mjs +180 -0
- package/lib/telemetry/outbox.mjs +224 -0
- package/lib/templates.mjs +53 -65
- package/lib/verify/checks/agents-on-disk.mjs +58 -0
- package/lib/verify/checks/api-reachable.mjs +39 -0
- package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
- package/lib/verify/checks/bootstrap-files.mjs +67 -0
- package/lib/verify/checks/cache-integrity.mjs +51 -0
- package/lib/verify/checks/ci-secrets.mjs +86 -0
- package/lib/verify/checks/config-present.mjs +39 -0
- package/lib/verify/checks/gitignore-cache.mjs +51 -0
- package/lib/verify/checks/rules-markers.mjs +135 -0
- package/lib/verify/checks/stack-enums.mjs +33 -0
- package/lib/verify/checks/tracker-labels.mjs +91 -0
- package/lib/verify/registry.mjs +120 -0
- package/lib/version.mjs +34 -0
- package/package.json +10 -3
- package/bin/ship.mjs +0 -68
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipctl run` — single entry-point for executing a Ship lane (RFC-0007).
|
|
3
|
+
*
|
|
4
|
+
* Today's scope (Phase 1):
|
|
5
|
+
* - `kind=once` lanes run with local idempotency markers.
|
|
6
|
+
* - `kind=event` and `kind=schedule` lanes execute when Ship's trigger
|
|
7
|
+
* router says they are due; router-side audit state prevents duplicate
|
|
8
|
+
* runs for the same schedule window.
|
|
9
|
+
*
|
|
10
|
+
* The command intentionally does not fork an agent subprocess. The
|
|
11
|
+
* reusable workflow pipes shipctl's stdout into the customer's agent
|
|
12
|
+
* (Cursor Cloud, Claude Code, Codex, …) the same way `shipctl kickoff`
|
|
13
|
+
* does today. That keeps the CLI agnostic about which agent runtime is
|
|
14
|
+
* in use.
|
|
15
|
+
*
|
|
16
|
+
* Callback behaviour: if a callback URL is available via flags or env,
|
|
17
|
+
* `shipctl run` reports `status=ok` on success and `status=fail` on any
|
|
18
|
+
* failure path. Callback errors do not override the primary exit code
|
|
19
|
+
* (a successful lane with a flaky callback still exits 0, but prints a
|
|
20
|
+
* warning to stderr).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import { readConfig, findShipRoot } from "../config/io.mjs";
|
|
27
|
+
import {
|
|
28
|
+
validateConfig,
|
|
29
|
+
CONFIG_SCHEMA_VERSION,
|
|
30
|
+
lanePatterns,
|
|
31
|
+
laneFanout,
|
|
32
|
+
LANE_FANOUT_MODES,
|
|
33
|
+
} from "../config/schema.mjs";
|
|
34
|
+
import { fetchArtifact } from "../http.mjs";
|
|
35
|
+
import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
|
|
36
|
+
import { readArtifactFile } from "../artifacts/fs-index.mjs";
|
|
37
|
+
import { decideRun, readMarker, writeMarker, sha256 } from "../state/idempotency.mjs";
|
|
38
|
+
import { readLockfile, lookupLock, verifyBody } from "../state/lockfile.mjs";
|
|
39
|
+
|
|
40
|
+
const EXIT_OK = 0;
|
|
41
|
+
const EXIT_USAGE = 1;
|
|
42
|
+
const EXIT_V1_CONFIG = 2;
|
|
43
|
+
const EXIT_CALLBACK = 3;
|
|
44
|
+
const EXIT_IDEMPOTENCY = 4;
|
|
45
|
+
|
|
46
|
+
const VALID_TRIGGERS = new Set(["event", "schedule", "manual", "once"]);
|
|
47
|
+
|
|
48
|
+
function printHelp() {
|
|
49
|
+
console.log(`shipctl run — execute a Ship lane (Automation in the operator console).
|
|
50
|
+
|
|
51
|
+
WHAT THIS COMMAND IS FOR
|
|
52
|
+
shipctl run is the **one-shot dispatch** entry point. It resolves a
|
|
53
|
+
lane from .ship/config.yml, fetches its pattern body, checks
|
|
54
|
+
idempotency, and emits the prompt for an agent to consume. Behaviour
|
|
55
|
+
by lane kind:
|
|
56
|
+
- kind: once — executed fully here, locally.
|
|
57
|
+
- kind: lane / event / — recognised but NOT executed locally;
|
|
58
|
+
schedule those run via the workspace's GitHub
|
|
59
|
+
Actions runner using the reusable
|
|
60
|
+
.github/workflows/run-agent.yml. shipctl
|
|
61
|
+
run exits 0 with a no-op summary so CI
|
|
62
|
+
wrappers can wire them safely.
|
|
63
|
+
|
|
64
|
+
USAGE
|
|
65
|
+
shipctl run --lane <id> [--pattern <id>] [--fanout <matrix|sequential|concurrent>]
|
|
66
|
+
[--trigger <event|schedule|manual|once>]
|
|
67
|
+
[--dry-run] [--offline]
|
|
68
|
+
[--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
|
|
69
|
+
[--cwd <dir>] [--json]
|
|
70
|
+
|
|
71
|
+
FLAGS
|
|
72
|
+
--lane <id> Lane id declared in .ship/config.yml. Required.
|
|
73
|
+
--pattern <id> For multi-pattern lanes: run only this pattern. This
|
|
74
|
+
is the per-entry call issued by the matrix workflow
|
|
75
|
+
(one matrix job per pattern). Must be one of the
|
|
76
|
+
lane's declared patterns.
|
|
77
|
+
--fanout <mode> Override the lane's configured fan-out for this run
|
|
78
|
+
(matrix|sequential|concurrent). Meaningful only
|
|
79
|
+
when the lane has ≥2 patterns and --pattern is not
|
|
80
|
+
set. Matrix mode without --pattern is rejected;
|
|
81
|
+
it requires a driving workflow.
|
|
82
|
+
--trigger <kind> Force the trigger context (event|schedule|manual|once).
|
|
83
|
+
If omitted, inferred from GITHUB_EVENT_NAME / SHIP_RUN_TRIGGER.
|
|
84
|
+
--dry-run Print the plan without touching idempotency markers or callback.
|
|
85
|
+
--offline Resolve patterns exclusively via .ship/shipctl.lock.json
|
|
86
|
+
and .ship/cache/ — never talks to the methodology API.
|
|
87
|
+
Fails if the lockfile or a cached body is missing.
|
|
88
|
+
Generate one with 'shipctl sync --lock'.
|
|
89
|
+
--ship-run-id <uuid> Pipeline run id. Falls back to SHIP_RUN_ID env.
|
|
90
|
+
--ship-callback-url <url> Full callback URL. Falls back to SHIP_CALLBACK_URL env.
|
|
91
|
+
--ship-run-token <jwt> Short-lived bearer. Falls back to SHIP_RUN_TOKEN env.
|
|
92
|
+
--cwd <dir> Repo root. Default: search upward for .ship/config.yml.
|
|
93
|
+
--json Emit a structured summary on stdout.
|
|
94
|
+
--help Show this help.
|
|
95
|
+
|
|
96
|
+
EXIT
|
|
97
|
+
0 lane executed or no-op
|
|
98
|
+
1 usage / config error
|
|
99
|
+
2 config is v1 — run 'shipctl migrate' first
|
|
100
|
+
3 callback failed (lane itself may have succeeded)
|
|
101
|
+
4 idempotency marker read/write failure
|
|
102
|
+
10 missing SHIP_RUN_TOKEN when a callback URL is configured
|
|
103
|
+
|
|
104
|
+
EXAMPLE (CI step emitted by the reusable workflow)
|
|
105
|
+
shipctl run --lane seed_knowledge_starters | feed-to-agent
|
|
106
|
+
`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {{json?: boolean, dryRun?: boolean, baseUrl?: string}} ctx
|
|
111
|
+
* @param {string[]} rest
|
|
112
|
+
*/
|
|
113
|
+
export async function runCommand(ctx, rest) {
|
|
114
|
+
const args = parseArgs(rest);
|
|
115
|
+
if (args.help) {
|
|
116
|
+
printHelp();
|
|
117
|
+
process.exit(EXIT_OK);
|
|
118
|
+
}
|
|
119
|
+
if (!args.lane) {
|
|
120
|
+
die(EXIT_USAGE, "`--lane <id>` is required.\nRun: shipctl run --help");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cwd = args.cwd || process.cwd();
|
|
124
|
+
const root = findShipRoot(cwd);
|
|
125
|
+
if (!root) {
|
|
126
|
+
die(
|
|
127
|
+
EXIT_USAGE,
|
|
128
|
+
`.ship/config.yml not found (searched from ${path.resolve(cwd)} upward). Run 'shipctl init' first.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let config;
|
|
133
|
+
try {
|
|
134
|
+
const read = readConfig(cwd);
|
|
135
|
+
config = read.config;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
die(EXIT_USAGE, err instanceof Error ? err.message : String(err));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (config.version !== CONFIG_SCHEMA_VERSION) {
|
|
141
|
+
die(
|
|
142
|
+
EXIT_V1_CONFIG,
|
|
143
|
+
`.ship/config.yml is at v${config.version}; shipctl run requires v${CONFIG_SCHEMA_VERSION}.\nRun 'shipctl migrate' to upgrade.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const validation = validateConfig(config);
|
|
148
|
+
if (!validation.ok) {
|
|
149
|
+
const msg = [
|
|
150
|
+
"config is invalid:",
|
|
151
|
+
...validation.errors.map((e) => ` - ${e}`),
|
|
152
|
+
].join("\n");
|
|
153
|
+
die(EXIT_USAGE, msg);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const lane = config.lanes?.[args.lane];
|
|
157
|
+
if (!lane) {
|
|
158
|
+
const known = Object.keys(config.lanes || {}).sort();
|
|
159
|
+
die(
|
|
160
|
+
EXIT_USAGE,
|
|
161
|
+
`unknown lane '${args.lane}'. Known lanes: ${known.length ? known.join(", ") : "(none)"}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const effectiveTrigger = resolveTrigger(args.trigger, lane.kind);
|
|
166
|
+
if (!effectiveTrigger.fits) {
|
|
167
|
+
/* Not an error — scheduler fired us but the lane doesn't want this
|
|
168
|
+
* trigger. Exit 0 so parallel lanes in the same workflow don't all
|
|
169
|
+
* fail just because one didn't match. */
|
|
170
|
+
const summary = {
|
|
171
|
+
lane: args.lane,
|
|
172
|
+
kind: lane.kind,
|
|
173
|
+
trigger: effectiveTrigger.trigger,
|
|
174
|
+
status: "noop",
|
|
175
|
+
reason: `lane.kind=${lane.kind} does not accept trigger=${effectiveTrigger.trigger}`,
|
|
176
|
+
};
|
|
177
|
+
emitSummary(ctx, args, summary);
|
|
178
|
+
process.exit(EXIT_OK);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// RFC-0008 C3.1/C3.2: resolve the list of patterns that this invocation
|
|
182
|
+
// should execute.
|
|
183
|
+
//
|
|
184
|
+
// --pattern <id> → run only that pattern (the per-entry call
|
|
185
|
+
// issued by the matrix workflow). The pattern
|
|
186
|
+
// must be one of the lane's declared patterns,
|
|
187
|
+
// otherwise we refuse so typos don't silently
|
|
188
|
+
// execute an unrelated pattern.
|
|
189
|
+
// (none) → run every pattern the lane declares, using
|
|
190
|
+
// the lane's fan-out mode. Matrix mode without
|
|
191
|
+
// --pattern is rejected because it requires a
|
|
192
|
+
// driving workflow (see run-agent.yml).
|
|
193
|
+
const allPatterns = lanePatterns(lane);
|
|
194
|
+
if (allPatterns.length === 0) {
|
|
195
|
+
die(EXIT_USAGE, `lane ${JSON.stringify(args.lane)} declares no patterns.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const effectiveFanout = args.fanout || laneFanout(lane);
|
|
199
|
+
let patternsToRun;
|
|
200
|
+
let runMode; // ``single`` | ``sequential`` | ``concurrent``
|
|
201
|
+
if (args.pattern) {
|
|
202
|
+
if (!allPatterns.includes(args.pattern)) {
|
|
203
|
+
die(
|
|
204
|
+
EXIT_USAGE,
|
|
205
|
+
`--pattern=${JSON.stringify(args.pattern)} is not declared on lane ${JSON.stringify(args.lane)}. ` +
|
|
206
|
+
`Known patterns: ${allPatterns.join(", ")}.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
patternsToRun = [args.pattern];
|
|
210
|
+
runMode = "single";
|
|
211
|
+
} else if (allPatterns.length === 1) {
|
|
212
|
+
patternsToRun = allPatterns;
|
|
213
|
+
runMode = "single";
|
|
214
|
+
} else if (effectiveFanout === "matrix") {
|
|
215
|
+
die(
|
|
216
|
+
EXIT_USAGE,
|
|
217
|
+
`lane ${JSON.stringify(args.lane)} has fanout=matrix and ${allPatterns.length} patterns ` +
|
|
218
|
+
`but no --pattern was provided. Matrix mode dispatches one 'shipctl run --pattern <id>' per ` +
|
|
219
|
+
`pattern via the workflow (see run-agent.yml). To run them in-process instead, pass ` +
|
|
220
|
+
`--fanout sequential or --fanout concurrent.`,
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
patternsToRun = allPatterns;
|
|
224
|
+
runMode = effectiveFanout;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Idempotency markers are lane-scoped (not per-pattern) so we read
|
|
228
|
+
// once up front; per-pattern decisions are derived from the
|
|
229
|
+
// concatenated pattern SHA set below so a change to any member of
|
|
230
|
+
// the list re-triggers the run (expected behaviour for audit lanes).
|
|
231
|
+
const idem = lane.kind === "once" ? lane.idempotency : null;
|
|
232
|
+
let marker = null;
|
|
233
|
+
if (idem) {
|
|
234
|
+
try {
|
|
235
|
+
marker = readMarker(cwd, idem.key);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
|
|
238
|
+
die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Fetch every pattern body first so we can reject the whole run
|
|
243
|
+
// atomically if any one is unavailable — partial success is worse
|
|
244
|
+
// than a hard failure here (the caller can retry once the fetch
|
|
245
|
+
// error is cleared).
|
|
246
|
+
const fetchJobs = patternsToRun.map((patternId) =>
|
|
247
|
+
fetchPatternBody({
|
|
248
|
+
patternId,
|
|
249
|
+
patternVersion: lane.pattern_version || null,
|
|
250
|
+
offline: args.offline,
|
|
251
|
+
root,
|
|
252
|
+
ctx,
|
|
253
|
+
config,
|
|
254
|
+
}).then((result) => ({ patternId, result })),
|
|
255
|
+
);
|
|
256
|
+
// `sequential` vs `concurrent` only differ for future in-process agent
|
|
257
|
+
// invocation; today's CLI just emits the pattern bodies to stdout, so
|
|
258
|
+
// both modes fetch in parallel and print in declared order. We still
|
|
259
|
+
// record the requested mode on the summary so downstream consumers
|
|
260
|
+
// (and future work) can see the intent.
|
|
261
|
+
const fetched = await Promise.all(fetchJobs);
|
|
262
|
+
for (const { patternId, result } of fetched) {
|
|
263
|
+
if (!result.ok) {
|
|
264
|
+
die(EXIT_USAGE, `pattern ${patternId}: ${result.error}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const runs = fetched.map(({ patternId, result }) => ({
|
|
268
|
+
patternId,
|
|
269
|
+
body: result.body,
|
|
270
|
+
source: result.source,
|
|
271
|
+
sha256: sha256(result.body),
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
// Composite SHA over all pattern bodies. ``reset_on=version-change``
|
|
275
|
+
// fires when any member's body drifts — which is the correct
|
|
276
|
+
// semantics for a multi-pattern audit lane: if one role's playbook
|
|
277
|
+
// updates, we want the whole lane to re-run.
|
|
278
|
+
const compositeBody = runs.map((r) => `#${r.patternId}\n${r.body}`).join("\n---\n");
|
|
279
|
+
const decision = idem
|
|
280
|
+
? decideRun(marker, compositeBody, idem.reset_on || "version-change")
|
|
281
|
+
: { run: true, reason: "trigger-router-due", marker: null };
|
|
282
|
+
if (!decision.run) {
|
|
283
|
+
const summary = {
|
|
284
|
+
lane: args.lane,
|
|
285
|
+
kind: lane.kind,
|
|
286
|
+
trigger: effectiveTrigger.trigger,
|
|
287
|
+
status: "noop",
|
|
288
|
+
reason: "already-done",
|
|
289
|
+
marker: decision.marker,
|
|
290
|
+
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
|
|
291
|
+
};
|
|
292
|
+
await tryCallback(
|
|
293
|
+
args,
|
|
294
|
+
"ok",
|
|
295
|
+
`lane ${args.lane}: already completed, no-op.`,
|
|
296
|
+
runMode === "single"
|
|
297
|
+
? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256, noop: true }
|
|
298
|
+
: { patterns: runs.map((r) => r.patternId), noop: true },
|
|
299
|
+
);
|
|
300
|
+
emitSummary(ctx, args, summary);
|
|
301
|
+
process.exit(EXIT_OK);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* Dry-run stops here — no marker write, no callback, just print the
|
|
305
|
+
* plan. We still emit the pattern bodies to stdout so operators can
|
|
306
|
+
* eyeball what the agent would receive. Multi-pattern runs print
|
|
307
|
+
* each body preceded by a ``# ship: pattern=<id>`` banner so the
|
|
308
|
+
* agent-side (or a human) can split them back apart. */
|
|
309
|
+
if (args.dryRun || ctx.dryRun) {
|
|
310
|
+
const summary = {
|
|
311
|
+
lane: args.lane,
|
|
312
|
+
kind: lane.kind,
|
|
313
|
+
trigger: effectiveTrigger.trigger,
|
|
314
|
+
status: "dry-run",
|
|
315
|
+
reason: decision.reason,
|
|
316
|
+
mode: runMode,
|
|
317
|
+
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
|
|
318
|
+
};
|
|
319
|
+
if (runs.length === 1) {
|
|
320
|
+
summary.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
|
|
321
|
+
}
|
|
322
|
+
if (ctx.json || args.json) {
|
|
323
|
+
console.log(
|
|
324
|
+
JSON.stringify(
|
|
325
|
+
{ ...summary, pattern_bodies: Object.fromEntries(runs.map((r) => [r.patternId, r.body])) },
|
|
326
|
+
null,
|
|
327
|
+
2,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
console.error(
|
|
332
|
+
`# ship: lane=${args.lane} kind=${lane.kind} trigger=${effectiveTrigger.trigger} mode=${runMode} (dry-run)`,
|
|
333
|
+
);
|
|
334
|
+
emitPatternBodies(runs, { json: false });
|
|
335
|
+
}
|
|
336
|
+
process.exit(EXIT_OK);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* Emit the prompt(s) for the agent to consume (same contract as
|
|
340
|
+
* `shipctl kickoff`). The reusable workflow pipes stdout into the
|
|
341
|
+
* configured agent runtime; multi-pattern output is delimited by a
|
|
342
|
+
* banner line per pattern so consumers can split on it.
|
|
343
|
+
*
|
|
344
|
+
* Workspace policy injection (RFC-Workspace-policy): before any
|
|
345
|
+
* pattern body, fetch the workspace's prose-rule policies from the
|
|
346
|
+
* backend and prepend them as a markdown block. This makes the
|
|
347
|
+
* agent treat the policies as hard preamble — the same shape the
|
|
348
|
+
* Navigator chat injects into ``TopicService.assemble_messages``.
|
|
349
|
+
* Best-effort: a missing token, missing callback URL, or a network
|
|
350
|
+
* failure quietly skips the prepend so local / offline runs still
|
|
351
|
+
* work. */
|
|
352
|
+
if (!(ctx.json || args.json)) {
|
|
353
|
+
const provider = resolveAgentProvider(config, args.lane);
|
|
354
|
+
if (provider) console.error(`# ship: lane=${args.lane} agent.provider=${provider} mode=${runMode}`);
|
|
355
|
+
const preamble = await fetchPoliciesPreamble(args);
|
|
356
|
+
if (preamble) emitPoliciesPreamble(preamble);
|
|
357
|
+
emitPatternBodies(runs, { json: false });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (idem) {
|
|
361
|
+
try {
|
|
362
|
+
writeMarker(cwd, idem.key, {
|
|
363
|
+
lane: args.lane,
|
|
364
|
+
pattern_id: runs[0].patternId,
|
|
365
|
+
pattern_sha256: sha256(compositeBody),
|
|
366
|
+
pattern_version: lane.pattern_version || null,
|
|
367
|
+
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256 })),
|
|
368
|
+
});
|
|
369
|
+
} catch (err) {
|
|
370
|
+
await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
|
|
371
|
+
die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const callbackMetrics = runMode === "single"
|
|
376
|
+
? { pattern_id: runs[0].patternId, pattern_sha256: runs[0].sha256 }
|
|
377
|
+
: {
|
|
378
|
+
pattern_id: runs[0].patternId,
|
|
379
|
+
pattern_sha256: runs[0].sha256,
|
|
380
|
+
patterns: runs.map((r) => r.patternId).join(","),
|
|
381
|
+
};
|
|
382
|
+
const callbackSummary = runMode === "single"
|
|
383
|
+
? `lane ${args.lane} completed (pattern ${runs[0].patternId}@${runs[0].sha256.slice(0, 8)}).`
|
|
384
|
+
: `lane ${args.lane} completed (${runs.length} patterns, mode=${runMode}).`;
|
|
385
|
+
const callbackResult = await tryCallback(args, "ok", callbackSummary, callbackMetrics);
|
|
386
|
+
|
|
387
|
+
if (ctx.json || args.json) {
|
|
388
|
+
// For single-pattern runs we keep the legacy ``pattern: {…}`` key
|
|
389
|
+
// alongside the new ``patterns: […]`` list so existing consumers
|
|
390
|
+
// (and tests) don't break when they upgrade shipctl before
|
|
391
|
+
// starting to declare multi-pattern lanes.
|
|
392
|
+
const summaryPayload = {
|
|
393
|
+
lane: args.lane,
|
|
394
|
+
kind: lane.kind,
|
|
395
|
+
trigger: effectiveTrigger.trigger,
|
|
396
|
+
status: "completed",
|
|
397
|
+
mode: runMode,
|
|
398
|
+
patterns: runs.map((r) => ({ id: r.patternId, sha256: r.sha256, source: r.source })),
|
|
399
|
+
callback: callbackResult,
|
|
400
|
+
};
|
|
401
|
+
if (runs.length === 1) {
|
|
402
|
+
summaryPayload.pattern = { id: runs[0].patternId, sha256: runs[0].sha256, source: runs[0].source };
|
|
403
|
+
}
|
|
404
|
+
console.log(JSON.stringify(summaryPayload, null, 2));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
process.exit(callbackResult.ok === false ? EXIT_CALLBACK : EXIT_OK);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Stream pattern bodies to stdout. For single-pattern runs we write
|
|
412
|
+
* the body as-is (identical byte output to the pre-multi-pattern
|
|
413
|
+
* behaviour, keeping the test harness stable). For multi-pattern
|
|
414
|
+
* runs we precede each body with a ``# ship: pattern=<id>`` banner so
|
|
415
|
+
* downstream consumers can re-split the stream.
|
|
416
|
+
*/
|
|
417
|
+
function emitPatternBodies(runs, _opts) {
|
|
418
|
+
if (runs.length === 1) {
|
|
419
|
+
const body = runs[0].body;
|
|
420
|
+
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
for (const r of runs) {
|
|
424
|
+
process.stdout.write(`# ship: pattern=${r.patternId} sha256=${r.sha256}\n`);
|
|
425
|
+
const body = r.body;
|
|
426
|
+
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Print the workspace-policies preamble once at the top of stdout,
|
|
432
|
+
* before the first pattern body. Trailing ``---`` separator visually
|
|
433
|
+
* distinguishes the preamble from the pattern markdown the agent is
|
|
434
|
+
* about to consume; an extra blank line above the separator keeps
|
|
435
|
+
* the markdown well-formed if the preamble already ends with one.
|
|
436
|
+
*/
|
|
437
|
+
function emitPoliciesPreamble(preamble) {
|
|
438
|
+
const trimmed = preamble.endsWith("\n") ? preamble : `${preamble}\n`;
|
|
439
|
+
process.stdout.write(trimmed);
|
|
440
|
+
process.stdout.write("\n---\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Fetch the workspace prose-rule policies for the current run from
|
|
445
|
+
* the Ship backend. The endpoint URL is derived from the callback
|
|
446
|
+
* URL by swapping the trailing ``/result`` segment for
|
|
447
|
+
* ``/policies-preamble`` — both share the same auth dependency
|
|
448
|
+
* (per-run JWT or long-lived ``SHIP_RUN_TOKEN``), so we can reuse
|
|
449
|
+
* the same bearer.
|
|
450
|
+
*
|
|
451
|
+
* Returns the preamble markdown or ``null`` when:
|
|
452
|
+
* - there's no callback URL / token (local invocation),
|
|
453
|
+
* - the URL doesn't end in ``/result`` (someone overrode the
|
|
454
|
+
* callback endpoint to a non-canonical path — too risky to
|
|
455
|
+
* guess),
|
|
456
|
+
* - the backend has no enabled policies (``preamble: null``),
|
|
457
|
+
* - or the request fails for any reason.
|
|
458
|
+
*
|
|
459
|
+
* Failures are surfaced as ``warn:`` lines on stderr so an operator
|
|
460
|
+
* can debug them without breaking the lane execution.
|
|
461
|
+
*/
|
|
462
|
+
async function fetchPoliciesPreamble(args) {
|
|
463
|
+
const callbackUrl = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
|
|
464
|
+
if (!callbackUrl) return null;
|
|
465
|
+
const token = args.runToken || process.env.SHIP_RUN_TOKEN;
|
|
466
|
+
if (!token) return null;
|
|
467
|
+
if (!callbackUrl.endsWith("/result")) {
|
|
468
|
+
console.error(
|
|
469
|
+
`warn: SHIP_CALLBACK_URL does not end in /result; skipping policies-preamble fetch (got ${callbackUrl}).`,
|
|
470
|
+
);
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const url = `${callbackUrl.slice(0, -"/result".length)}/policies-preamble`;
|
|
474
|
+
try {
|
|
475
|
+
const res = await fetch(url, {
|
|
476
|
+
method: "GET",
|
|
477
|
+
headers: {
|
|
478
|
+
Accept: "application/json",
|
|
479
|
+
Authorization: `Bearer ${token}`,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
console.error(
|
|
484
|
+
`warn: policies-preamble fetch returned HTTP ${res.status} ${res.statusText}; continuing without policies.`,
|
|
485
|
+
);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const body = await res.json().catch(() => null);
|
|
489
|
+
if (!body || typeof body !== "object") return null;
|
|
490
|
+
const preamble = body.preamble;
|
|
491
|
+
if (typeof preamble !== "string" || !preamble.trim()) return null;
|
|
492
|
+
return preamble;
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error(
|
|
495
|
+
`warn: policies-preamble fetch failed: ${err instanceof Error ? err.message : err}`,
|
|
496
|
+
);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
/* ------------------------------------------------------------------ */
|
|
503
|
+
/* Helpers */
|
|
504
|
+
/* ------------------------------------------------------------------ */
|
|
505
|
+
|
|
506
|
+
function parseArgs(rest) {
|
|
507
|
+
const out = {
|
|
508
|
+
lane: null,
|
|
509
|
+
pattern: null,
|
|
510
|
+
fanout: null,
|
|
511
|
+
trigger: null,
|
|
512
|
+
dryRun: false,
|
|
513
|
+
offline: false,
|
|
514
|
+
runId: null,
|
|
515
|
+
callbackUrl: null,
|
|
516
|
+
runToken: null,
|
|
517
|
+
cwd: null,
|
|
518
|
+
json: false,
|
|
519
|
+
help: false,
|
|
520
|
+
};
|
|
521
|
+
const copy = [...rest];
|
|
522
|
+
const str = (flag, key) => {
|
|
523
|
+
if (copy[0] === flag && copy[1] !== undefined) {
|
|
524
|
+
copy.shift();
|
|
525
|
+
out[key] = String(copy.shift());
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
const p = `${flag}=`;
|
|
529
|
+
if (copy[0] && copy[0].startsWith(p)) {
|
|
530
|
+
out[key] = copy[0].slice(p.length);
|
|
531
|
+
copy.shift();
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
};
|
|
536
|
+
while (copy.length) {
|
|
537
|
+
const a = copy[0];
|
|
538
|
+
if (a === "--help" || a === "-h") {
|
|
539
|
+
out.help = true;
|
|
540
|
+
copy.shift();
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (a === "--dry-run") {
|
|
544
|
+
out.dryRun = true;
|
|
545
|
+
copy.shift();
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (a === "--offline") {
|
|
549
|
+
out.offline = true;
|
|
550
|
+
copy.shift();
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (a === "--json") {
|
|
554
|
+
out.json = true;
|
|
555
|
+
copy.shift();
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (str("--lane", "lane")) continue;
|
|
559
|
+
if (str("--pattern", "pattern")) continue;
|
|
560
|
+
if (str("--fanout", "fanout")) continue;
|
|
561
|
+
if (str("--trigger", "trigger")) continue;
|
|
562
|
+
if (str("--ship-run-id", "runId")) continue;
|
|
563
|
+
if (str("--ship-callback-url", "callbackUrl")) continue;
|
|
564
|
+
if (str("--ship-run-token", "runToken")) continue;
|
|
565
|
+
if (str("--cwd", "cwd")) {
|
|
566
|
+
out.cwd = path.resolve(out.cwd);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl run --help`);
|
|
570
|
+
}
|
|
571
|
+
if (out.trigger && !VALID_TRIGGERS.has(out.trigger)) {
|
|
572
|
+
die(
|
|
573
|
+
EXIT_USAGE,
|
|
574
|
+
`--trigger must be one of ${[...VALID_TRIGGERS].join("|")}; got ${out.trigger}`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (out.fanout && !LANE_FANOUT_MODES.includes(out.fanout)) {
|
|
578
|
+
die(
|
|
579
|
+
EXIT_USAGE,
|
|
580
|
+
`--fanout must be one of ${LANE_FANOUT_MODES.join("|")}; got ${out.fanout}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (out.pattern !== null && (typeof out.pattern !== "string" || !out.pattern.trim())) {
|
|
584
|
+
die(EXIT_USAGE, "--pattern: must be a non-empty pattern id");
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function resolveTrigger(explicit, laneKind) {
|
|
590
|
+
const raw =
|
|
591
|
+
explicit ||
|
|
592
|
+
(process.env.SHIP_RUN_TRIGGER && process.env.SHIP_RUN_TRIGGER.trim()) ||
|
|
593
|
+
inferFromEnv();
|
|
594
|
+
const trigger = raw || "manual";
|
|
595
|
+
|
|
596
|
+
/* `once` lanes only run under `manual` or `once` triggers. Scheduler
|
|
597
|
+
* or event triggers must not accidentally repeat seeding because the
|
|
598
|
+
* cron happens to tick. */
|
|
599
|
+
if (laneKind === "once") {
|
|
600
|
+
return { fits: trigger === "manual" || trigger === "once", trigger };
|
|
601
|
+
}
|
|
602
|
+
if (laneKind === "schedule") {
|
|
603
|
+
return { fits: trigger === "schedule" || trigger === "manual", trigger };
|
|
604
|
+
}
|
|
605
|
+
if (laneKind === "event") {
|
|
606
|
+
return { fits: trigger === "event" || trigger === "manual", trigger };
|
|
607
|
+
}
|
|
608
|
+
return { fits: false, trigger };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function inferFromEnv() {
|
|
612
|
+
if (process.env.GITHUB_EVENT_NAME === "schedule") return "schedule";
|
|
613
|
+
if (process.env.GITHUB_EVENT_NAME === "workflow_dispatch") return "manual";
|
|
614
|
+
if (process.env.GITHUB_EVENT_NAME) return "event";
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function resolveAgentProvider(config, laneId) {
|
|
619
|
+
const override = config.agent?.overrides?.[laneId]?.provider;
|
|
620
|
+
if (override) return override;
|
|
621
|
+
return config.agent?.default?.provider || null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function fetchPatternBody({ patternId, patternVersion, offline, root, ctx, config }) {
|
|
625
|
+
/* --offline takes precedence when requested: we MUST NOT hit the
|
|
626
|
+
* network or fall through to another source. The lockfile is the
|
|
627
|
+
* single source of truth. This makes CI runs reproducible and keeps
|
|
628
|
+
* air-gapped installs honest. */
|
|
629
|
+
if (offline) return fetchFromLockfile({ patternId, root, strict: true });
|
|
630
|
+
|
|
631
|
+
/* 1) Running inside the Ship monorepo — read from disk. */
|
|
632
|
+
const shipRepo = resolveShipRepoRootForCatalog();
|
|
633
|
+
if (shipRepo) {
|
|
634
|
+
const file = readArtifactFile(shipRepo, "pattern", patternId);
|
|
635
|
+
if (file) {
|
|
636
|
+
const verification = verifyAgainstLockfile({
|
|
637
|
+
root,
|
|
638
|
+
patternId,
|
|
639
|
+
body: file.content,
|
|
640
|
+
});
|
|
641
|
+
if (verification.warning) console.error(`warn: ${verification.warning}`);
|
|
642
|
+
return { ok: true, body: file.content, source: "monorepo", lock: verification };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/* 2) Network: same resolver `shipctl kickoff` uses. */
|
|
647
|
+
const base = resolveMethodologyBase(ctx, config);
|
|
648
|
+
try {
|
|
649
|
+
const { content } = await fetchArtifact(base, "pattern", patternId, patternVersion || undefined);
|
|
650
|
+
const verification = verifyAgainstLockfile({ root, patternId, body: content });
|
|
651
|
+
if (verification.warning) console.error(`warn: ${verification.warning}`);
|
|
652
|
+
return { ok: true, body: content, source: "http", lock: verification };
|
|
653
|
+
} catch (err) {
|
|
654
|
+
/* If the network call failed but we have a locked copy on disk, let
|
|
655
|
+
* the operator fall back with a clear warning. This mirrors the
|
|
656
|
+
* `npm install --offline` escape hatch when the registry is down. */
|
|
657
|
+
const fallback = fetchFromLockfile({ patternId, root, strict: false });
|
|
658
|
+
if (fallback.ok) {
|
|
659
|
+
console.error(
|
|
660
|
+
`warn: network fetch failed for pattern/${patternId}; using locked copy (${fallback.source}).`,
|
|
661
|
+
);
|
|
662
|
+
return fallback;
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
ok: false,
|
|
666
|
+
error: `failed to fetch pattern ${patternId}: ${err instanceof Error ? err.message : err}`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function verifyAgainstLockfile({ root, patternId, body }) {
|
|
672
|
+
let lock;
|
|
673
|
+
try {
|
|
674
|
+
lock = readLockfile(root);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return { present: false, ok: null, warning: `lockfile unreadable: ${err.message}` };
|
|
677
|
+
}
|
|
678
|
+
if (!lock) return { present: false, ok: null };
|
|
679
|
+
const entry = lookupLock(lock, "pattern", patternId);
|
|
680
|
+
if (!entry) {
|
|
681
|
+
return {
|
|
682
|
+
present: true,
|
|
683
|
+
ok: null,
|
|
684
|
+
warning: `lockfile present but has no entry for pattern/${patternId}; run 'shipctl sync --lock'.`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const result = verifyBody(entry, body);
|
|
688
|
+
if (!result.ok) {
|
|
689
|
+
return {
|
|
690
|
+
present: true,
|
|
691
|
+
ok: false,
|
|
692
|
+
reason: result.reason,
|
|
693
|
+
expected: result.expected,
|
|
694
|
+
actual: result.actual,
|
|
695
|
+
warning: `pattern/${patternId} sha256 drift vs lockfile (${result.reason}; expected ${result.expected?.slice(0, 8)} got ${result.actual?.slice(0, 8)})`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return { present: true, ok: true, version: entry.version };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function fetchFromLockfile({ patternId, root, strict }) {
|
|
702
|
+
let lock;
|
|
703
|
+
try {
|
|
704
|
+
lock = readLockfile(root);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
error: `lockfile unreadable: ${err.message}. Run 'shipctl sync --lock' to rebuild.`,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
if (!lock) {
|
|
712
|
+
if (!strict) return { ok: false, error: "lockfile missing" };
|
|
713
|
+
return {
|
|
714
|
+
ok: false,
|
|
715
|
+
error:
|
|
716
|
+
"--offline requires .ship/shipctl.lock.json. Run 'shipctl sync --lock' in an online environment first.",
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
const entry = lookupLock(lock, "pattern", patternId);
|
|
720
|
+
if (!entry) {
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
error:
|
|
724
|
+
strict
|
|
725
|
+
? `--offline: pattern/${patternId} missing from .ship/shipctl.lock.json. Run 'shipctl sync --lock' to re-resolve.`
|
|
726
|
+
: `pattern/${patternId} not in lockfile`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const abs = path.join(root, entry.cached_path);
|
|
730
|
+
let body;
|
|
731
|
+
try {
|
|
732
|
+
body = fs.readFileSync(abs, "utf8");
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return {
|
|
735
|
+
ok: false,
|
|
736
|
+
error: `--offline: cached pattern body unreadable at ${entry.cached_path} (${err instanceof Error ? err.message : err}). Run 'shipctl sync --lock'.`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const verification = verifyBody(entry, body);
|
|
740
|
+
if (!verification.ok) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
error: `--offline: sha256 mismatch for pattern/${patternId} (expected ${verification.expected?.slice(0, 8)}, got ${verification.actual?.slice(0, 8)}). Re-run 'shipctl sync --lock'.`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
ok: true,
|
|
748
|
+
body,
|
|
749
|
+
source: "lockfile",
|
|
750
|
+
lock: { present: true, ok: true, version: entry.version },
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function resolveMethodologyBase(ctx, config) {
|
|
755
|
+
const fromFlag = ctx.baseUrl;
|
|
756
|
+
const fromEnv =
|
|
757
|
+
typeof process.env.SHIP_API_BASE === "string" && process.env.SHIP_API_BASE.trim()
|
|
758
|
+
? process.env.SHIP_API_BASE.trim().replace(/\/$/, "")
|
|
759
|
+
: null;
|
|
760
|
+
/* Wizard-seeded Actions secret: exact Ship API origin (``POST /fetch`` lives
|
|
761
|
+
* at the root next to ``/v1``). Do not append ``/api/methodology`` here. */
|
|
762
|
+
if (fromEnv) {
|
|
763
|
+
return fromEnv;
|
|
764
|
+
}
|
|
765
|
+
const raw = config?.api?.base_url;
|
|
766
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
767
|
+
const u = raw.replace(/\/$/, "");
|
|
768
|
+
return u.includes("/api/methodology") ? u : `${u}/api/methodology`;
|
|
769
|
+
}
|
|
770
|
+
return fromFlag;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/*
|
|
774
|
+
* Assemble the callback `metrics` bag so Ship's backend can tie each
|
|
775
|
+
* run back to its lane + GitHub Actions run without re-parsing logs.
|
|
776
|
+
*
|
|
777
|
+
* Always-on breadcrumbs (iff we have the data):
|
|
778
|
+
* - lane_id — id from `.ship/config.yml`; also recoverable
|
|
779
|
+
* from the ship-<lane_id>.yml workflow path,
|
|
780
|
+
* but duplicating here costs us nothing and
|
|
781
|
+
* makes non-GitHub adapters (RFC-0007 Phase 8)
|
|
782
|
+
* cheaper because they won't have that URL.
|
|
783
|
+
* - gh_workflow_run_id — GITHUB_RUN_ID env (empty outside Actions).
|
|
784
|
+
* - gh_html_url — constructed from GITHUB_SERVER_URL / _REPOSITORY
|
|
785
|
+
* / _RUN_ID so the Console can deep-link the
|
|
786
|
+
* GH UI from a Lane detail view.
|
|
787
|
+
* - gh_event — GITHUB_EVENT_NAME (push / schedule / PR /…).
|
|
788
|
+
*
|
|
789
|
+
* Caller-supplied extras (pattern id / sha) stack on top. Nothing here
|
|
790
|
+
* is required; the backend treats unknown keys as opaque forward-compat
|
|
791
|
+
* payload.
|
|
792
|
+
*/
|
|
793
|
+
function collectCallbackMetrics(args, extra = {}) {
|
|
794
|
+
const env = process.env;
|
|
795
|
+
const out = { ...(extra || {}) };
|
|
796
|
+
if (args && args.lane && !out.lane_id) out.lane_id = args.lane;
|
|
797
|
+
if (env.GITHUB_RUN_ID && !out.gh_workflow_run_id) {
|
|
798
|
+
out.gh_workflow_run_id = env.GITHUB_RUN_ID;
|
|
799
|
+
}
|
|
800
|
+
if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID && !out.gh_html_url) {
|
|
801
|
+
out.gh_html_url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;
|
|
802
|
+
}
|
|
803
|
+
if (env.GITHUB_EVENT_NAME && !out.gh_event) out.gh_event = env.GITHUB_EVENT_NAME;
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function tryCallback(args, status, summary, extraMetrics = {}) {
|
|
808
|
+
const url = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
|
|
809
|
+
if (!url) return { ok: null, skipped: "no-callback-url" };
|
|
810
|
+
const token = args.runToken || process.env.SHIP_RUN_TOKEN;
|
|
811
|
+
if (!token) {
|
|
812
|
+
console.error(
|
|
813
|
+
"warn: SHIP_RUN_TOKEN missing; skipping callback. (Set via --ship-run-token or env.)",
|
|
814
|
+
);
|
|
815
|
+
return { ok: false, skipped: "no-token" };
|
|
816
|
+
}
|
|
817
|
+
const body = { status: status === "ok" ? "succeeded" : status === "fail" ? "failed" : status };
|
|
818
|
+
if (summary) body.summary = String(summary).slice(0, 1024);
|
|
819
|
+
const metrics = collectCallbackMetrics(args, extraMetrics);
|
|
820
|
+
if (Object.keys(metrics).length > 0) body.metrics = metrics;
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
const res = await fetch(url, {
|
|
824
|
+
method: "POST",
|
|
825
|
+
headers: {
|
|
826
|
+
"Content-Type": "application/json",
|
|
827
|
+
Accept: "application/json",
|
|
828
|
+
Authorization: `Bearer ${token}`,
|
|
829
|
+
},
|
|
830
|
+
body: JSON.stringify(body),
|
|
831
|
+
});
|
|
832
|
+
if (!res.ok) {
|
|
833
|
+
const text = await res.text().catch(() => "");
|
|
834
|
+
console.error(`warn: callback returned HTTP ${res.status} ${res.statusText}\n${text}`);
|
|
835
|
+
return { ok: false, status: res.status };
|
|
836
|
+
}
|
|
837
|
+
return { ok: true, status: res.status };
|
|
838
|
+
} catch (err) {
|
|
839
|
+
console.error(`warn: callback POST failed: ${err instanceof Error ? err.message : err}`);
|
|
840
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function emitSummary(ctx, args, summary) {
|
|
845
|
+
if (ctx.json || args.json) {
|
|
846
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
847
|
+
} else {
|
|
848
|
+
console.error(
|
|
849
|
+
`# ship: lane=${summary.lane} status=${summary.status}${summary.reason ? ` reason="${summary.reason}"` : ""}`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function die(code, msg) {
|
|
855
|
+
console.error(msg);
|
|
856
|
+
process.exit(code);
|
|
857
|
+
}
|