@rtrentjones/greenlight 0.2.10 → 0.2.12
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.js +148 -27
- package/package.json +4 -4
package/dist/bin.js
CHANGED
|
@@ -414,7 +414,7 @@ function tokensForTool(tool) {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
// src/version.ts
|
|
417
|
-
var MODULE_REF = "v0.2.
|
|
417
|
+
var MODULE_REF = "v0.2.12";
|
|
418
418
|
var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
|
|
419
419
|
function moduleSource(module, ref = MODULE_REF) {
|
|
420
420
|
return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
|
|
@@ -1099,10 +1099,17 @@ function vendorDeps(vendorDir) {
|
|
|
1099
1099
|
}
|
|
1100
1100
|
return out;
|
|
1101
1101
|
}
|
|
1102
|
-
function starterVerifyConfig(lane) {
|
|
1103
|
-
const spec = lane === "mcp" ? "
|
|
1102
|
+
function starterVerifyConfig(lane, target) {
|
|
1103
|
+
const spec = lane === "mcp" ? "mode: 'mcp', expectTools: []" : "mode: 'api', checks: [{ path: '/', status: 200 }]";
|
|
1104
|
+
const logHint = target === "vercel" ? 'vercel logs "$GREENLIGHT_VERIFY_URL" --token "$VERCEL_API_TOKEN" 2>&1 | head -40 || true' : 'curl -sS -i "$GREENLIGHT_VERIFY_URL" 2>&1 | head -30 || true';
|
|
1104
1105
|
return `// Greenlight verify spec \u2014 edit to assert this tool's real contract.
|
|
1105
|
-
export default
|
|
1106
|
+
export default {
|
|
1107
|
+
${spec},
|
|
1108
|
+
// Telemetry-into-verify: a shell command run ONLY when this report FAILS; its last ~50 lines
|
|
1109
|
+
// attach to the report so the agent/CI sees the "why" in-loop. $GREENLIGHT_VERIFY_URL is the
|
|
1110
|
+
// failing URL (no hard-coding). Best-effort (never fails the gate). Uncomment + adjust:
|
|
1111
|
+
// logsOnFailure: '${logHint}',
|
|
1112
|
+
};
|
|
1106
1113
|
`;
|
|
1107
1114
|
}
|
|
1108
1115
|
function infraTf(name, domain, lane, target, data, envs, slug) {
|
|
@@ -1294,26 +1301,8 @@ jobs:
|
|
|
1294
1301
|
-F client_payload[sha]=\${{ github.sha }}
|
|
1295
1302
|
`;
|
|
1296
1303
|
}
|
|
1297
|
-
function
|
|
1298
|
-
return `
|
|
1299
|
-
|
|
1300
|
-
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
1301
|
-
on:
|
|
1302
|
-
repository_dispatch:
|
|
1303
|
-
types: [deploy-${name}]
|
|
1304
|
-
workflow_dispatch:
|
|
1305
|
-
|
|
1306
|
-
permissions:
|
|
1307
|
-
contents: read
|
|
1308
|
-
|
|
1309
|
-
jobs:
|
|
1310
|
-
deploy:
|
|
1311
|
-
runs-on: ubuntu-latest
|
|
1312
|
-
steps:
|
|
1313
|
-
- uses: actions/checkout@v4
|
|
1314
|
-
- uses: jdx/mise-action@v2
|
|
1315
|
-
- run: pnpm install --frozen-lockfile
|
|
1316
|
-
- run: pip install --quiet oci-cli
|
|
1304
|
+
function ociDeployAndVerifySteps(name) {
|
|
1305
|
+
return ` - run: pip install --quiet oci-cli
|
|
1317
1306
|
- name: Deploy (resolve instance OCID by name -> restart -> re-pull GHCR image)
|
|
1318
1307
|
env:
|
|
1319
1308
|
# The OCI CLI reuses the SAME TF_VAR_OCI_* secrets the apply uses \u2014 one secret set.
|
|
@@ -1346,7 +1335,33 @@ jobs:
|
|
|
1346
1335
|
# The deploy "succeeds" only if the NEW image is actually serving. verify has a built-in
|
|
1347
1336
|
# readiness wait (re-pull + container start). A failure here fails the job \u2192 the status
|
|
1348
1337
|
# posted back is red. oci is verify-gated direct-to-prod (no cheap standing beta on free A1).
|
|
1349
|
-
run: pnpm exec greenlight verify ${name} --env prod
|
|
1338
|
+
run: pnpm exec greenlight verify ${name} --env prod`;
|
|
1339
|
+
}
|
|
1340
|
+
function deployListenerYml(name, toolRepo) {
|
|
1341
|
+
return `name: greenlight-deploy-${name}
|
|
1342
|
+
|
|
1343
|
+
# Option B: ${toolRepo} fires repository_dispatch(deploy-${name}) after pushing a new image.
|
|
1344
|
+
on:
|
|
1345
|
+
repository_dispatch:
|
|
1346
|
+
types: [deploy-${name}]
|
|
1347
|
+
workflow_dispatch:
|
|
1348
|
+
|
|
1349
|
+
permissions:
|
|
1350
|
+
contents: read
|
|
1351
|
+
|
|
1352
|
+
# Share the self-heal workflow's group so a deploy and a remediation never run at the same time.
|
|
1353
|
+
concurrency:
|
|
1354
|
+
group: deploy-${name}
|
|
1355
|
+
cancel-in-progress: false
|
|
1356
|
+
|
|
1357
|
+
jobs:
|
|
1358
|
+
deploy:
|
|
1359
|
+
runs-on: ubuntu-latest
|
|
1360
|
+
steps:
|
|
1361
|
+
- uses: actions/checkout@v4
|
|
1362
|
+
- uses: jdx/mise-action@v2
|
|
1363
|
+
- run: pnpm install --frozen-lockfile
|
|
1364
|
+
${ociDeployAndVerifySteps(name)}
|
|
1350
1365
|
- name: Report status back to ${toolRepo}
|
|
1351
1366
|
if: \${{ always() && github.event.client_payload.sha != '' }}
|
|
1352
1367
|
env:
|
|
@@ -1360,6 +1375,65 @@ jobs:
|
|
|
1360
1375
|
-f description="\${{ job.status }}"
|
|
1361
1376
|
`;
|
|
1362
1377
|
}
|
|
1378
|
+
function remediateYml(name) {
|
|
1379
|
+
return `name: greenlight-remediate-${name}
|
|
1380
|
+
|
|
1381
|
+
# Auto-heal: the keepalive Worker dispatches remediate-${name} when ${name} (oci) is unreachable.
|
|
1382
|
+
on:
|
|
1383
|
+
repository_dispatch:
|
|
1384
|
+
types: [remediate-${name}]
|
|
1385
|
+
workflow_dispatch:
|
|
1386
|
+
|
|
1387
|
+
permissions:
|
|
1388
|
+
contents: read
|
|
1389
|
+
issues: write
|
|
1390
|
+
|
|
1391
|
+
# Same group as greenlight-deploy-${name}: a self-heal never overlaps a deploy or another heal, and
|
|
1392
|
+
# re-applying an already-healthy instance is idempotent (no diff) \u2014 anti-flap with no extra state.
|
|
1393
|
+
concurrency:
|
|
1394
|
+
group: deploy-${name}
|
|
1395
|
+
cancel-in-progress: false
|
|
1396
|
+
|
|
1397
|
+
jobs:
|
|
1398
|
+
remediate:
|
|
1399
|
+
runs-on: ubuntu-latest
|
|
1400
|
+
steps:
|
|
1401
|
+
- uses: actions/checkout@v4
|
|
1402
|
+
- uses: jdx/mise-action@v2
|
|
1403
|
+
- run: pnpm install --frozen-lockfile
|
|
1404
|
+
- uses: hashicorp/setup-terraform@v3
|
|
1405
|
+
with:
|
|
1406
|
+
terraform_version: '~1.10'
|
|
1407
|
+
terraform_wrapper: false
|
|
1408
|
+
- name: Re-apply the instance (recreate it if OCI idle-reclaimed the Always-Free box)
|
|
1409
|
+
env:
|
|
1410
|
+
TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
|
|
1411
|
+
TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
|
|
1412
|
+
TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
|
|
1413
|
+
TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
|
|
1414
|
+
TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
|
|
1415
|
+
TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
|
|
1416
|
+
TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
|
|
1417
|
+
run: |
|
|
1418
|
+
if [ -z "$TF_TOKEN_app_terraform_io" ]; then
|
|
1419
|
+
echo "::warning::no TF_API_TOKEN \u2014 skipping re-apply; will still attempt a restart below"
|
|
1420
|
+
exit 0
|
|
1421
|
+
fi
|
|
1422
|
+
terraform -chdir=infra init -input=false
|
|
1423
|
+
# -target pulls in the instance's deps (the ${name}_network module) automatically.
|
|
1424
|
+
terraform -chdir=infra apply -input=false -auto-approve -target=module.${name}_instance
|
|
1425
|
+
${ociDeployAndVerifySteps(name)}
|
|
1426
|
+
- name: Escalate if the self-heal failed
|
|
1427
|
+
if: \${{ failure() }}
|
|
1428
|
+
env:
|
|
1429
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1430
|
+
run: |
|
|
1431
|
+
gh issue create --repo \${{ github.repository }} \\
|
|
1432
|
+
--title "remediate-${name}: self-heal FAILED" \\
|
|
1433
|
+
--body "Automatic remediation for ${name} (reason: \${{ github.event.client_payload.reason }}) did not bring prod back. Manual attention needed." \\
|
|
1434
|
+
--label keepalive || true
|
|
1435
|
+
`;
|
|
1436
|
+
}
|
|
1363
1437
|
function verifyWorkflowYml(name) {
|
|
1364
1438
|
return `name: greenlight-verify
|
|
1365
1439
|
|
|
@@ -1405,12 +1479,18 @@ function nextVerifyConfig(name) {
|
|
|
1405
1479
|
// Unit tests belong in this repo's PR CI; to also gate the deploy on them, add
|
|
1406
1480
|
// { mode: 'test', command: 'pnpm test' } + a tolerant deps-install step in greenlight-verify.yml.
|
|
1407
1481
|
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
1482
|
+
// Telemetry-into-verify: on a FAILED report, fetch the Vercel deployment's runtime logs and attach
|
|
1483
|
+
// the tail to the report (best-effort, never fails the gate). $GREENLIGHT_VERIFY_URL is the exact
|
|
1484
|
+
// failing deployment URL (injected \u2014 no hard-coding); needs VERCEL_API_TOKEN in CI.
|
|
1485
|
+
const logsOnFailure =
|
|
1486
|
+
'vercel logs "$GREENLIGHT_VERIFY_URL" --token "$VERCEL_API_TOKEN" 2>&1 | head -40 || true';
|
|
1408
1487
|
const api = bypass
|
|
1409
1488
|
? {
|
|
1410
1489
|
mode: 'api',
|
|
1411
1490
|
checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
|
|
1491
|
+
logsOnFailure,
|
|
1412
1492
|
}
|
|
1413
|
-
: { mode: 'api', checks: [{ path: '/', status: 401 }] };
|
|
1493
|
+
: { mode: 'api', checks: [{ path: '/', status: 401 }], logsOnFailure };
|
|
1414
1494
|
const agentWeb = process.env.ANTHROPIC_API_KEY
|
|
1415
1495
|
? [
|
|
1416
1496
|
{
|
|
@@ -1512,7 +1592,7 @@ async function adoptWrapper(ctx) {
|
|
|
1512
1592
|
if (target !== "vercel") {
|
|
1513
1593
|
writeIfAbsent(
|
|
1514
1594
|
join2(cwd, `verify/${name}.config.ts`),
|
|
1515
|
-
starterVerifyConfig(lane),
|
|
1595
|
+
starterVerifyConfig(lane, target),
|
|
1516
1596
|
`verify/${name}.config.ts`
|
|
1517
1597
|
);
|
|
1518
1598
|
}
|
|
@@ -1529,6 +1609,11 @@ async function adoptWrapper(ctx) {
|
|
|
1529
1609
|
deployListenerYml(name, slug),
|
|
1530
1610
|
`.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
|
|
1531
1611
|
);
|
|
1612
|
+
writeIfAbsent(
|
|
1613
|
+
join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
|
|
1614
|
+
remediateYml(name),
|
|
1615
|
+
`.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
|
|
1616
|
+
);
|
|
1532
1617
|
writeIfAbsent(
|
|
1533
1618
|
join2(dest, ".github/workflows/greenlight-build.yml"),
|
|
1534
1619
|
containerBuildYml(name, wrapperSlug),
|
|
@@ -2120,6 +2205,7 @@ import { resolve as resolve10 } from "path";
|
|
|
2120
2205
|
import { setTimeout as sleep } from "timers/promises";
|
|
2121
2206
|
|
|
2122
2207
|
// src/commands/verify.ts
|
|
2208
|
+
import { spawnSync } from "child_process";
|
|
2123
2209
|
import { resolve as resolve9 } from "path";
|
|
2124
2210
|
function defaultSpec(lane) {
|
|
2125
2211
|
switch (lane) {
|
|
@@ -2139,6 +2225,39 @@ function printReport(report) {
|
|
|
2139
2225
|
}
|
|
2140
2226
|
console.log(`
|
|
2141
2227
|
${report.pass ? "\u2714 PASS" : "\u2718 FAIL"}`);
|
|
2228
|
+
if (!report.pass && report.logs) {
|
|
2229
|
+
console.log(`
|
|
2230
|
+
--- recent logs (${report.mode}) ---
|
|
2231
|
+
${report.logs}
|
|
2232
|
+
--- end logs ---`);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
var LOG_TAIL_LINES = 50;
|
|
2236
|
+
function attachFailureLogs(reports, specs, toolDir) {
|
|
2237
|
+
reports.forEach((report, i) => {
|
|
2238
|
+
if (report.pass) return;
|
|
2239
|
+
const cmd = specs[i]?.logsOnFailure;
|
|
2240
|
+
if (!cmd) {
|
|
2241
|
+
report.logs = "(no logsOnFailure configured for this spec)";
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
try {
|
|
2245
|
+
const res = spawnSync(cmd, {
|
|
2246
|
+
shell: true,
|
|
2247
|
+
cwd: toolDir,
|
|
2248
|
+
timeout: 3e4,
|
|
2249
|
+
encoding: "utf8",
|
|
2250
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2251
|
+
// Let the command target the exact failing URL without hard-coding it.
|
|
2252
|
+
env: { ...process.env, GREENLIGHT_VERIFY_URL: report.url }
|
|
2253
|
+
});
|
|
2254
|
+
const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd();
|
|
2255
|
+
const tail = out.split("\n").slice(-LOG_TAIL_LINES).join("\n");
|
|
2256
|
+
report.logs = tail || `(logsOnFailure produced no output${res.error ? `: ${res.error.message}` : ""})`;
|
|
2257
|
+
} catch (e) {
|
|
2258
|
+
report.logs = `(log fetch failed: ${e instanceof Error ? e.message : String(e)})`;
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2142
2261
|
}
|
|
2143
2262
|
function flag6(args, name) {
|
|
2144
2263
|
const i = args.indexOf(name);
|
|
@@ -2157,6 +2276,7 @@ async function verifyCommand(args) {
|
|
|
2157
2276
|
reachableTimeoutMs: waitMs,
|
|
2158
2277
|
toolDir: process.cwd()
|
|
2159
2278
|
});
|
|
2279
|
+
attachFailureLogs(reports2, specs2, process.cwd());
|
|
2160
2280
|
for (const report of reports2) printReport(report);
|
|
2161
2281
|
const pass2 = allPass(reports2);
|
|
2162
2282
|
if (reports2.length > 1)
|
|
@@ -2194,6 +2314,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
|
|
|
2194
2314
|
}
|
|
2195
2315
|
const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
|
|
2196
2316
|
const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
|
|
2317
|
+
attachFailureLogs(reports, specs, toolDir);
|
|
2197
2318
|
for (const report of reports) printReport(report);
|
|
2198
2319
|
const pass = allPass(reports);
|
|
2199
2320
|
if (reports.length > 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@anthropic-ai/sdk": "^0.69.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@rtrentjones/greenlight-
|
|
34
|
+
"@rtrentjones/greenlight-adapters": "0.2.4",
|
|
35
35
|
"@rtrentjones/greenlight-shared": "0.2.4",
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
36
|
+
"@rtrentjones/greenlight-loop": "0.2.4",
|
|
37
|
+
"@rtrentjones/greenlight-verify": "0.2.4"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|