@rtrentjones/greenlight 0.2.10 → 0.2.11
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 +144 -27
- package/package.json +3 -3
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.11";
|
|
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 === "oci" ? "oci logging-search search-logs ... // the instance/container logs" : target === "vercel" ? "vercel logs <deployment-url> --token $VERCEL_API_TOKEN" : "wrangler tail --once // workers observability";
|
|
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. Best-effort (never fails the
|
|
1110
|
+
// 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,16 @@ 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). Needs VERCEL_API_TOKEN in CI.
|
|
1484
|
+
const logsOnFailure = 'vercel logs "$DEPLOYMENT_URL" --token "$VERCEL_API_TOKEN" 2>&1 || true';
|
|
1408
1485
|
const api = bypass
|
|
1409
1486
|
? {
|
|
1410
1487
|
mode: 'api',
|
|
1411
1488
|
checks: [{ path: '/', status: 200, requestHeaders: { 'x-vercel-protection-bypass': bypass } }],
|
|
1489
|
+
logsOnFailure,
|
|
1412
1490
|
}
|
|
1413
|
-
: { mode: 'api', checks: [{ path: '/', status: 401 }] };
|
|
1491
|
+
: { mode: 'api', checks: [{ path: '/', status: 401 }], logsOnFailure };
|
|
1414
1492
|
const agentWeb = process.env.ANTHROPIC_API_KEY
|
|
1415
1493
|
? [
|
|
1416
1494
|
{
|
|
@@ -1512,7 +1590,7 @@ async function adoptWrapper(ctx) {
|
|
|
1512
1590
|
if (target !== "vercel") {
|
|
1513
1591
|
writeIfAbsent(
|
|
1514
1592
|
join2(cwd, `verify/${name}.config.ts`),
|
|
1515
|
-
starterVerifyConfig(lane),
|
|
1593
|
+
starterVerifyConfig(lane, target),
|
|
1516
1594
|
`verify/${name}.config.ts`
|
|
1517
1595
|
);
|
|
1518
1596
|
}
|
|
@@ -1529,6 +1607,11 @@ async function adoptWrapper(ctx) {
|
|
|
1529
1607
|
deployListenerYml(name, slug),
|
|
1530
1608
|
`.github/workflows/greenlight-deploy-${name}.yml (wrapper deploy listener)`
|
|
1531
1609
|
);
|
|
1610
|
+
writeIfAbsent(
|
|
1611
|
+
join2(cwd, `.github/workflows/greenlight-remediate-${name}.yml`),
|
|
1612
|
+
remediateYml(name),
|
|
1613
|
+
`.github/workflows/greenlight-remediate-${name}.yml (wrapper self-heal listener)`
|
|
1614
|
+
);
|
|
1532
1615
|
writeIfAbsent(
|
|
1533
1616
|
join2(dest, ".github/workflows/greenlight-build.yml"),
|
|
1534
1617
|
containerBuildYml(name, wrapperSlug),
|
|
@@ -2120,6 +2203,7 @@ import { resolve as resolve10 } from "path";
|
|
|
2120
2203
|
import { setTimeout as sleep } from "timers/promises";
|
|
2121
2204
|
|
|
2122
2205
|
// src/commands/verify.ts
|
|
2206
|
+
import { spawnSync } from "child_process";
|
|
2123
2207
|
import { resolve as resolve9 } from "path";
|
|
2124
2208
|
function defaultSpec(lane) {
|
|
2125
2209
|
switch (lane) {
|
|
@@ -2139,6 +2223,37 @@ function printReport(report) {
|
|
|
2139
2223
|
}
|
|
2140
2224
|
console.log(`
|
|
2141
2225
|
${report.pass ? "\u2714 PASS" : "\u2718 FAIL"}`);
|
|
2226
|
+
if (!report.pass && report.logs) {
|
|
2227
|
+
console.log(`
|
|
2228
|
+
--- recent logs (${report.mode}) ---
|
|
2229
|
+
${report.logs}
|
|
2230
|
+
--- end logs ---`);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
var LOG_TAIL_LINES = 50;
|
|
2234
|
+
function attachFailureLogs(reports, specs, toolDir) {
|
|
2235
|
+
reports.forEach((report, i) => {
|
|
2236
|
+
if (report.pass) return;
|
|
2237
|
+
const cmd = specs[i]?.logsOnFailure;
|
|
2238
|
+
if (!cmd) {
|
|
2239
|
+
report.logs = "(no logsOnFailure configured for this spec)";
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
try {
|
|
2243
|
+
const res = spawnSync(cmd, {
|
|
2244
|
+
shell: true,
|
|
2245
|
+
cwd: toolDir,
|
|
2246
|
+
timeout: 3e4,
|
|
2247
|
+
encoding: "utf8",
|
|
2248
|
+
maxBuffer: 10 * 1024 * 1024
|
|
2249
|
+
});
|
|
2250
|
+
const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trimEnd();
|
|
2251
|
+
const tail = out.split("\n").slice(-LOG_TAIL_LINES).join("\n");
|
|
2252
|
+
report.logs = tail || `(logsOnFailure produced no output${res.error ? `: ${res.error.message}` : ""})`;
|
|
2253
|
+
} catch (e) {
|
|
2254
|
+
report.logs = `(log fetch failed: ${e instanceof Error ? e.message : String(e)})`;
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2142
2257
|
}
|
|
2143
2258
|
function flag6(args, name) {
|
|
2144
2259
|
const i = args.indexOf(name);
|
|
@@ -2157,6 +2272,7 @@ async function verifyCommand(args) {
|
|
|
2157
2272
|
reachableTimeoutMs: waitMs,
|
|
2158
2273
|
toolDir: process.cwd()
|
|
2159
2274
|
});
|
|
2275
|
+
attachFailureLogs(reports2, specs2, process.cwd());
|
|
2160
2276
|
for (const report of reports2) printReport(report);
|
|
2161
2277
|
const pass2 = allPass(reports2);
|
|
2162
2278
|
if (reports2.length > 1)
|
|
@@ -2194,6 +2310,7 @@ ${pass2 ? "\u2714 ALL PASS" : "\u2718 FAIL"} (${reports2.length} specs)`);
|
|
|
2194
2310
|
}
|
|
2195
2311
|
const toolDir = resolve9(process.cwd(), entry.dir ?? ".");
|
|
2196
2312
|
const reports = await verifyAll(url, specs, { reachableTimeoutMs, toolDir });
|
|
2313
|
+
attachFailureLogs(reports, specs, toolDir);
|
|
2197
2314
|
for (const report of reports) printReport(report);
|
|
2198
2315
|
const pass = allPass(reports);
|
|
2199
2316
|
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.11",
|
|
4
4
|
"description": "Greenlight CLI — setup and lifecycle for the harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@rtrentjones/greenlight-loop": "0.2.4",
|
|
35
35
|
"@rtrentjones/greenlight-shared": "0.2.4",
|
|
36
|
-
"@rtrentjones/greenlight-
|
|
37
|
-
"@rtrentjones/greenlight-
|
|
36
|
+
"@rtrentjones/greenlight-adapters": "0.2.4",
|
|
37
|
+
"@rtrentjones/greenlight-verify": "0.2.4"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node scripts/copy-assets.mjs && tsup",
|