mandrel-platform 1.4.1 → 1.5.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/package.json +2 -2
- package/scripts/apply-uptime-monitors.mjs +89 -21
- package/scripts/apply-uptime-monitors.test.mjs +196 -19
- package/scripts/check-affected-mode.test.mjs +54 -1
- package/scripts/check-coverage-threshold.test.mjs +1 -1
- package/scripts/check-destructive-migration.mjs +6 -2
- package/scripts/check-osv-scan-mode.test.mjs +2 -2
- package/scripts/check-pin-drift.mjs +7 -5
- package/scripts/check-repo-settings.mjs +6 -4
- package/scripts/check-ruleset.mjs +6 -4
- package/scripts/check-runner-health.mjs +6 -4
- package/scripts/check-workflow-gh-flags.mjs +6 -2
- package/scripts/check-workflow-platform-checkout.mjs +319 -0
- package/scripts/check-workflow-platform-checkout.test.mjs +342 -0
- package/scripts/check-wrangler-baseline.mjs +126 -8
- package/scripts/check-wrangler-baseline.test.mjs +258 -1
- package/scripts/deploy-boot-smoke.mjs +1 -1
- package/scripts/deploy-worker-secrets.mjs +1 -1
- package/scripts/lib/entry-guard.mjs +111 -0
- package/scripts/lib/entry-guard.test.mjs +179 -0
- package/scripts/platform-repair.mjs +6 -4
- package/scripts/track-issue.test.mjs +280 -0
- package/scripts/update-semgrep-rules.mjs +6 -4
- package/templates/workflows/uptime-apply.yml +10 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mandrel-platform",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"mandrel": "^2.
|
|
47
|
+
"mandrel": "^2.35.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"typecheck": "node --input-type=module --eval 'process.exit(0)'",
|
|
@@ -43,9 +43,12 @@
|
|
|
43
43
|
* • --token Better Stack API token. Defaults to
|
|
44
44
|
* $BETTERSTACK_API_TOKEN. Missing token → skip-with-
|
|
45
45
|
* notice, exit 0.
|
|
46
|
-
* • --alert-email
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* • --alert-email DEPRECATED and ignored (defaults to $UPTIME_ALERT_EMAIL).
|
|
47
|
+
* Better Stack's monitor `email` field is a boolean
|
|
48
|
+
* ("Send e-mail alerts."), not a recipient — recipients
|
|
49
|
+
* resolve from the team roster and the escalation policy.
|
|
50
|
+
* Accepted only so an existing caller does not break; use
|
|
51
|
+
* a monitor entry's `policyId` to control who is alerted.
|
|
49
52
|
*
|
|
50
53
|
* Exit codes:
|
|
51
54
|
* 0 — plan computed / applied successfully, OR skip-with-notice (no token).
|
|
@@ -58,6 +61,8 @@
|
|
|
58
61
|
import { readFileSync } from "node:fs";
|
|
59
62
|
import { resolve } from "node:path";
|
|
60
63
|
|
|
64
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
65
|
+
|
|
61
66
|
// ---------------------------------------------------------------------------
|
|
62
67
|
// Monitor config schema (pure validation — no I/O)
|
|
63
68
|
// ---------------------------------------------------------------------------
|
|
@@ -71,12 +76,24 @@ import { resolve } from "node:path";
|
|
|
71
76
|
* {
|
|
72
77
|
* "url": "https://api.example.com/health", // required, http(s) URL
|
|
73
78
|
* "name": "api", // optional, defaults to url's host
|
|
74
|
-
* "
|
|
79
|
+
* "emailAlerts": true, // optional, boolean, default true
|
|
80
|
+
* "policyId": "12345", // optional, Better Stack escalation policy
|
|
75
81
|
* "checkFrequency": 30 // optional, seconds, default 30
|
|
76
82
|
* }
|
|
77
83
|
*
|
|
84
|
+
* `emailAlerts` maps to Better Stack's `email` field, which the Monitors API
|
|
85
|
+
* types as a **boolean** ("Send e-mail alerts.") — it is a switch, not a
|
|
86
|
+
* recipient. Recipients resolve from the Better Stack team roster and the
|
|
87
|
+
* escalation policy named by `policyId` (`policy_id`), never from a
|
|
88
|
+
* per-monitor address. The retired `alertEmail` field is still accepted and
|
|
89
|
+
* type-checked so an existing config keeps parsing, but it is inert: it is
|
|
90
|
+
* reported in `deprecations[]` and never reaches the API payload.
|
|
91
|
+
*
|
|
78
92
|
* @param {unknown} raw Parsed JSON.
|
|
79
|
-
* @returns {{
|
|
93
|
+
* @returns {{
|
|
94
|
+
* monitors: Array<{url:string, name:string, emailAlerts:boolean, policyId:string|null, checkFrequency:number}>,
|
|
95
|
+
* deprecations: string[]
|
|
96
|
+
* }}
|
|
80
97
|
* @throws {Error} with a message naming the offending index/field on invalid input.
|
|
81
98
|
*/
|
|
82
99
|
export function parseMonitorConfig(raw) {
|
|
@@ -86,9 +103,18 @@ export function parseMonitorConfig(raw) {
|
|
|
86
103
|
"monitor config must be a JSON array of monitor entries, or an object with a `monitors` array."
|
|
87
104
|
);
|
|
88
105
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
const deprecations = [];
|
|
107
|
+
const monitors = list.map((entry, i) => {
|
|
108
|
+
if (entry && typeof entry === "object" && entry.alertEmail !== undefined) {
|
|
109
|
+
deprecations.push(
|
|
110
|
+
`monitor config entry [${i}] sets "alertEmail" — that field is ignored. ` +
|
|
111
|
+
`Better Stack types the monitor's \`email\` field as a boolean (an on/off switch), not a recipient. ` +
|
|
112
|
+
`Use "emailAlerts" to toggle e-mail alerts and "policyId" to choose the escalation policy that decides who is alerted.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return validateMonitorEntry(entry, i);
|
|
116
|
+
});
|
|
117
|
+
return { monitors, deprecations };
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
function validateMonitorEntry(entry, index) {
|
|
@@ -110,13 +136,20 @@ function validateMonitorEntry(entry, index) {
|
|
|
110
136
|
if (entry.alertEmail !== undefined && typeof entry.alertEmail !== "string") {
|
|
111
137
|
throw new Error(`monitor config entry [${index}] "alertEmail" must be a string when present.`);
|
|
112
138
|
}
|
|
139
|
+
if (entry.emailAlerts !== undefined && typeof entry.emailAlerts !== "boolean") {
|
|
140
|
+
throw new Error(`monitor config entry [${index}] "emailAlerts" must be a boolean when present.`);
|
|
141
|
+
}
|
|
142
|
+
if (entry.policyId !== undefined && typeof entry.policyId !== "string") {
|
|
143
|
+
throw new Error(`monitor config entry [${index}] "policyId" must be a string when present.`);
|
|
144
|
+
}
|
|
113
145
|
if (entry.checkFrequency !== undefined && !(Number.isInteger(entry.checkFrequency) && entry.checkFrequency > 0)) {
|
|
114
146
|
throw new Error(`monitor config entry [${index}] "checkFrequency" must be a positive integer (seconds) when present.`);
|
|
115
147
|
}
|
|
116
148
|
return {
|
|
117
149
|
url: entry.url,
|
|
118
150
|
name: entry.name ?? host,
|
|
119
|
-
|
|
151
|
+
emailAlerts: entry.emailAlerts ?? true,
|
|
152
|
+
policyId: entry.policyId ?? null,
|
|
120
153
|
checkFrequency: entry.checkFrequency ?? DEFAULT_CHECK_FREQUENCY_SECONDS,
|
|
121
154
|
};
|
|
122
155
|
}
|
|
@@ -163,7 +196,13 @@ function normalizeUrl(url) {
|
|
|
163
196
|
function monitorNeedsUpdate(live, desired) {
|
|
164
197
|
if (live.name !== undefined && live.name !== desired.name) return true;
|
|
165
198
|
if (live.checkFrequency !== undefined && live.checkFrequency !== desired.checkFrequency) return true;
|
|
166
|
-
|
|
199
|
+
// Both sides are booleans: the live read-back maps Better Stack's boolean
|
|
200
|
+
// `email` attribute, and `emailAlerts` normalizes to a boolean at parse.
|
|
201
|
+
// Comparing a boolean against a configured address (the pre-#403 shape)
|
|
202
|
+
// read as drift on every beat and issued a redundant PATCH per monitor
|
|
203
|
+
// per apply.
|
|
204
|
+
if (live.emailAlerts !== undefined && live.emailAlerts !== desired.emailAlerts) return true;
|
|
205
|
+
if (live.policyId !== undefined && desired.policyId !== null && live.policyId !== desired.policyId) return true;
|
|
167
206
|
return false;
|
|
168
207
|
}
|
|
169
208
|
|
|
@@ -209,7 +248,8 @@ export function createBetterStackClient({ token, fetchImpl = fetch, apiBase = BE
|
|
|
209
248
|
url: m.attributes?.url ?? "",
|
|
210
249
|
name: m.attributes?.pronounceable_name,
|
|
211
250
|
checkFrequency: m.attributes?.check_frequency,
|
|
212
|
-
|
|
251
|
+
emailAlerts: m.attributes?.email,
|
|
252
|
+
policyId: m.attributes?.policy_id,
|
|
213
253
|
}));
|
|
214
254
|
},
|
|
215
255
|
async createMonitor(entry) {
|
|
@@ -231,7 +271,11 @@ function toBetterStackPayload(entry) {
|
|
|
231
271
|
url: entry.url,
|
|
232
272
|
pronounceable_name: entry.name,
|
|
233
273
|
check_frequency: entry.checkFrequency,
|
|
234
|
-
|
|
274
|
+
// `email` is a boolean in Better Stack's Monitors API ("Send e-mail
|
|
275
|
+
// alerts.") — always send the switch, never an address. `policy_id` is
|
|
276
|
+
// the field that actually determines who is alerted.
|
|
277
|
+
email: entry.emailAlerts,
|
|
278
|
+
...(entry.policyId ? { policy_id: entry.policyId } : {}),
|
|
235
279
|
};
|
|
236
280
|
}
|
|
237
281
|
|
|
@@ -245,14 +289,10 @@ function toBetterStackPayload(entry) {
|
|
|
245
289
|
* @param {{monitors: Array}} opts.config
|
|
246
290
|
* @param {ReturnType<typeof createBetterStackClient>} opts.client
|
|
247
291
|
* @param {boolean} opts.dryRun
|
|
248
|
-
* @param {string|null} [opts.defaultAlertEmail]
|
|
249
292
|
* @returns {Promise<{created: string[], updated: string[], unchanged: string[], dryRun: boolean}>}
|
|
250
293
|
*/
|
|
251
|
-
export async function applyMonitorConfig({ config, client, dryRun
|
|
252
|
-
const desired = config.monitors
|
|
253
|
-
...m,
|
|
254
|
-
alertEmail: m.alertEmail ?? defaultAlertEmail,
|
|
255
|
-
}));
|
|
294
|
+
export async function applyMonitorConfig({ config, client, dryRun }) {
|
|
295
|
+
const desired = config.monitors;
|
|
256
296
|
const live = await client.listMonitors();
|
|
257
297
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
258
298
|
|
|
@@ -322,10 +362,33 @@ async function main() {
|
|
|
322
362
|
process.exit(1);
|
|
323
363
|
}
|
|
324
364
|
|
|
365
|
+
// Announced before the token check on purpose: "secret provisioned, token
|
|
366
|
+
// absent" is exactly the state that produced a permanently-green apply a
|
|
367
|
+
// consumer believed was routing alerts, so that run is the one that most
|
|
368
|
+
// needs to hear the address is inert (refs #403).
|
|
369
|
+
if (opts.alertEmail) {
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
"::warning title=UPTIME_ALERT_EMAIL is ignored::Better Stack resolves alert recipients from the team roster " +
|
|
372
|
+
"and escalation policy, not from a per-monitor address. Set a monitor's `policyId` instead, and drop this secret from your caller.\n"
|
|
373
|
+
);
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
"[apply-uptime-monitors] DEPRECATED: --alert-email / $UPTIME_ALERT_EMAIL is ignored — Better Stack's monitor " +
|
|
376
|
+
"`email` field is a boolean switch, not a recipient. Use a monitor entry's `policyId` (escalation policy) to " +
|
|
377
|
+
"control who is alerted, and `emailAlerts` to toggle e-mail alerts.\n"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
325
381
|
// Graceful degradation: no token → skip-with-notice, exit 0. Preserves the
|
|
326
382
|
// pre-existing per-consumer behaviour when Better Stack secrets are not
|
|
327
383
|
// yet provisioned (acceptance criterion — see docs/reusable-workflows.md).
|
|
328
384
|
if (!opts.token) {
|
|
385
|
+
// Annotation-level, not stdout-only: a consumer can otherwise sit on a
|
|
386
|
+
// permanently-green `uptime-apply` for weeks with zero live monitors and
|
|
387
|
+
// never notice the apply is inert (refs #403).
|
|
388
|
+
process.stdout.write(
|
|
389
|
+
"::warning title=Uptime monitors not applied::BETTERSTACK_API_TOKEN is not provisioned — " +
|
|
390
|
+
"this uptime-apply run created and updated nothing. Provision the secret to activate uptime monitoring.\n"
|
|
391
|
+
);
|
|
329
392
|
process.stdout.write(
|
|
330
393
|
"⏭️ apply-uptime-monitors: BETTERSTACK_API_TOKEN not provided — skipping uptime-monitor apply (Better Stack not provisioned for this consumer yet).\n"
|
|
331
394
|
);
|
|
@@ -341,6 +404,10 @@ async function main() {
|
|
|
341
404
|
process.exit(1);
|
|
342
405
|
}
|
|
343
406
|
|
|
407
|
+
for (const notice of config.deprecations) {
|
|
408
|
+
process.stderr.write(`[apply-uptime-monitors] DEPRECATED: ${notice}\n`);
|
|
409
|
+
}
|
|
410
|
+
|
|
344
411
|
// Test-only escape hatch: point the CLI at a local/offline server instead
|
|
345
412
|
// of the live Better Stack API. Never set in a production caller — see
|
|
346
413
|
// createBetterStackClient's apiBase docblock.
|
|
@@ -355,7 +422,6 @@ async function main() {
|
|
|
355
422
|
config,
|
|
356
423
|
client,
|
|
357
424
|
dryRun: opts.dryRun,
|
|
358
|
-
defaultAlertEmail: opts.alertEmail,
|
|
359
425
|
});
|
|
360
426
|
const verb = result.dryRun ? "would create" : "created";
|
|
361
427
|
const verbUpdate = result.dryRun ? "would update" : "updated";
|
|
@@ -372,7 +438,9 @@ async function main() {
|
|
|
372
438
|
}
|
|
373
439
|
}
|
|
374
440
|
|
|
375
|
-
//
|
|
376
|
-
|
|
441
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
442
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
443
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
444
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
377
445
|
main();
|
|
378
446
|
}
|
|
@@ -42,16 +42,19 @@ test("parseMonitorConfig accepts a bare array of monitor entries", () => {
|
|
|
42
42
|
assert.equal(monitors.length, 1);
|
|
43
43
|
assert.equal(monitors[0].url, "https://api.example.com/health");
|
|
44
44
|
assert.equal(monitors[0].name, "api.example.com");
|
|
45
|
-
assert.equal(monitors[0].
|
|
45
|
+
assert.equal(monitors[0].emailAlerts, true, "e-mail alerts default to on");
|
|
46
|
+
assert.equal(monitors[0].policyId, null);
|
|
46
47
|
assert.equal(monitors[0].checkFrequency, DEFAULT_CHECK_FREQUENCY_SECONDS);
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
test("parseMonitorConfig accepts a wrapped { monitors: [...] } object", () => {
|
|
50
51
|
const { monitors } = parseMonitorConfig({
|
|
51
|
-
monitors: [
|
|
52
|
+
monitors: [
|
|
53
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: false, policyId: "pol-1", checkFrequency: 60 },
|
|
54
|
+
],
|
|
52
55
|
});
|
|
53
56
|
assert.deepEqual(monitors, [
|
|
54
|
-
{ url: "https://x.example.com", name: "x",
|
|
57
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: false, policyId: "pol-1", checkFrequency: 60 },
|
|
55
58
|
]);
|
|
56
59
|
});
|
|
57
60
|
|
|
@@ -83,12 +86,42 @@ test("parseMonitorConfig rejects non-string name/alertEmail", () => {
|
|
|
83
86
|
);
|
|
84
87
|
});
|
|
85
88
|
|
|
89
|
+
test("parseMonitorConfig rejects a non-boolean emailAlerts, naming the index", () => {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => parseMonitorConfig([{ url: "https://a.example.com" }, { url: "https://x.example.com", emailAlerts: "yes" }]),
|
|
92
|
+
/entry \[1\] "emailAlerts" must be a boolean/
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("parseMonitorConfig rejects a non-string policyId, naming the index", () => {
|
|
97
|
+
assert.throws(
|
|
98
|
+
() => parseMonitorConfig([{ url: "https://a.example.com" }, { url: "https://x.example.com", policyId: 12345 }]),
|
|
99
|
+
/entry \[1\] "policyId" must be a string/
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseMonitorConfig reports a retired alertEmail as a deprecation and drops it", () => {
|
|
104
|
+
const { monitors, deprecations } = parseMonitorConfig([
|
|
105
|
+
{ url: "https://x.example.com", alertEmail: "oncall@example.com" },
|
|
106
|
+
]);
|
|
107
|
+
assert.equal(deprecations.length, 1);
|
|
108
|
+
assert.match(deprecations[0], /entry \[0\] sets "alertEmail" — that field is ignored/);
|
|
109
|
+
assert.match(deprecations[0], /policyId/);
|
|
110
|
+
assert.equal(monitors[0].alertEmail, undefined, "the retired field never reaches the normalized entry");
|
|
111
|
+
assert.equal(monitors[0].emailAlerts, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("parseMonitorConfig reports no deprecations for a clean config", () => {
|
|
115
|
+
const { deprecations } = parseMonitorConfig([{ url: "https://x.example.com", emailAlerts: false }]);
|
|
116
|
+
assert.deepEqual(deprecations, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
86
119
|
// ---------------------------------------------------------------------------
|
|
87
120
|
// diffMonitors
|
|
88
121
|
// ---------------------------------------------------------------------------
|
|
89
122
|
|
|
90
123
|
test("diffMonitors classifies a brand-new url as toCreate", () => {
|
|
91
|
-
const desired = [{ url: "https://new.example.com", name: "new",
|
|
124
|
+
const desired = [{ url: "https://new.example.com", name: "new", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
92
125
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, []);
|
|
93
126
|
assert.equal(toCreate.length, 1);
|
|
94
127
|
assert.equal(toUpdate.length, 0);
|
|
@@ -96,7 +129,7 @@ test("diffMonitors classifies a brand-new url as toCreate", () => {
|
|
|
96
129
|
});
|
|
97
130
|
|
|
98
131
|
test("diffMonitors classifies a matching, identical url as unchanged", () => {
|
|
99
|
-
const desired = [{ url: "https://x.example.com", name: "x",
|
|
132
|
+
const desired = [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
100
133
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
101
134
|
const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
|
|
102
135
|
assert.equal(toCreate.length, 0);
|
|
@@ -105,7 +138,7 @@ test("diffMonitors classifies a matching, identical url as unchanged", () => {
|
|
|
105
138
|
});
|
|
106
139
|
|
|
107
140
|
test("diffMonitors classifies a url with a drifted name/frequency as toUpdate", () => {
|
|
108
|
-
const desired = [{ url: "https://x.example.com", name: "renamed",
|
|
141
|
+
const desired = [{ url: "https://x.example.com", name: "renamed", emailAlerts: true, policyId: null, checkFrequency: 60 }];
|
|
109
142
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
110
143
|
const { toUpdate } = diffMonitors(desired, live);
|
|
111
144
|
assert.equal(toUpdate.length, 1);
|
|
@@ -113,7 +146,7 @@ test("diffMonitors classifies a url with a drifted name/frequency as toUpdate",
|
|
|
113
146
|
});
|
|
114
147
|
|
|
115
148
|
test("diffMonitors url matching is trailing-slash and case insensitive", () => {
|
|
116
|
-
const desired = [{ url: "https://X.example.com/", name: "x",
|
|
149
|
+
const desired = [{ url: "https://X.example.com/", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
117
150
|
const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
|
|
118
151
|
const { toCreate, unchanged } = diffMonitors(desired, live);
|
|
119
152
|
assert.equal(toCreate.length, 0);
|
|
@@ -121,7 +154,7 @@ test("diffMonitors url matching is trailing-slash and case insensitive", () => {
|
|
|
121
154
|
});
|
|
122
155
|
|
|
123
156
|
test("diffMonitors never proposes deleting a live monitor absent from desired (additive apply only)", () => {
|
|
124
|
-
const desired = [{ url: "https://kept.example.com", name: "kept",
|
|
157
|
+
const desired = [{ url: "https://kept.example.com", name: "kept", emailAlerts: true, policyId: null, checkFrequency: 30 }];
|
|
125
158
|
const live = [
|
|
126
159
|
{ id: "1", url: "https://kept.example.com", name: "kept", checkFrequency: 30 },
|
|
127
160
|
{ id: "2", url: "https://hand-added.example.com", name: "manual" },
|
|
@@ -162,7 +195,16 @@ test("createBetterStackClient.listMonitors normalizes the Better Stack payload s
|
|
|
162
195
|
data: [
|
|
163
196
|
{
|
|
164
197
|
id: "42",
|
|
165
|
-
|
|
198
|
+
// `email` is a boolean in Better Stack's API — a switch, not a
|
|
199
|
+
// recipient. Fixturing it as an address is what let the
|
|
200
|
+
// string-typed payload bug survive a green suite (refs #403).
|
|
201
|
+
attributes: {
|
|
202
|
+
url: "https://a.example.com",
|
|
203
|
+
pronounceable_name: "a",
|
|
204
|
+
check_frequency: 30,
|
|
205
|
+
email: true,
|
|
206
|
+
policy_id: "pol-1",
|
|
207
|
+
},
|
|
166
208
|
},
|
|
167
209
|
],
|
|
168
210
|
},
|
|
@@ -171,7 +213,7 @@ test("createBetterStackClient.listMonitors normalizes the Better Stack payload s
|
|
|
171
213
|
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
172
214
|
const monitors = await client.listMonitors();
|
|
173
215
|
assert.deepEqual(monitors, [
|
|
174
|
-
{ id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30,
|
|
216
|
+
{ id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30, emailAlerts: true, policyId: "pol-1" },
|
|
175
217
|
]);
|
|
176
218
|
assert.equal(calls[0].url, "https://uptime.betterstack.com/api/v2/monitors");
|
|
177
219
|
});
|
|
@@ -209,7 +251,7 @@ function fakeClient({ live = [] } = {}) {
|
|
|
209
251
|
test("applyMonitorConfig dry-run computes the plan without calling create/update", async () => {
|
|
210
252
|
const client = fakeClient({ live: [] });
|
|
211
253
|
const result = await applyMonitorConfig({
|
|
212
|
-
config: { monitors: [{ url: "https://x.example.com", name: "x",
|
|
254
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
213
255
|
client,
|
|
214
256
|
dryRun: true,
|
|
215
257
|
});
|
|
@@ -221,7 +263,7 @@ test("applyMonitorConfig dry-run computes the plan without calling create/update
|
|
|
221
263
|
test("applyMonitorConfig --apply issues create for new monitors", async () => {
|
|
222
264
|
const client = fakeClient({ live: [] });
|
|
223
265
|
const result = await applyMonitorConfig({
|
|
224
|
-
config: { monitors: [{ url: "https://x.example.com", name: "x",
|
|
266
|
+
config: { monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
225
267
|
client,
|
|
226
268
|
dryRun: false,
|
|
227
269
|
});
|
|
@@ -229,21 +271,112 @@ test("applyMonitorConfig --apply issues create for new monitors", async () => {
|
|
|
229
271
|
assert.equal(client.created.length, 1);
|
|
230
272
|
});
|
|
231
273
|
|
|
232
|
-
test("
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
274
|
+
test("a created monitor sends Better Stack's `email` as a boolean, never an address", async () => {
|
|
275
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
276
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
277
|
+
});
|
|
278
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
279
|
+
await client.createMonitor({
|
|
280
|
+
url: "https://x.example.com",
|
|
281
|
+
name: "x",
|
|
282
|
+
emailAlerts: true,
|
|
283
|
+
policyId: null,
|
|
284
|
+
checkFrequency: 30,
|
|
285
|
+
});
|
|
286
|
+
const body = JSON.parse(calls[0].body);
|
|
287
|
+
assert.equal(typeof body.email, "boolean", "`email` is a boolean switch in Better Stack's API");
|
|
288
|
+
assert.equal(body.email, true);
|
|
289
|
+
assert.ok(!("policy_id" in body), "no escalation policy is sent when the entry names none");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("emailAlerts:false switches e-mail alerts off rather than omitting the field", async () => {
|
|
293
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
294
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
295
|
+
});
|
|
296
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
297
|
+
await client.createMonitor({
|
|
298
|
+
url: "https://x.example.com",
|
|
299
|
+
name: "x",
|
|
300
|
+
emailAlerts: false,
|
|
301
|
+
policyId: null,
|
|
302
|
+
checkFrequency: 30,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(JSON.parse(calls[0].body).email, false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("policyId maps to Better Stack's policy_id — the field that decides who is alerted", async () => {
|
|
308
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
309
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
310
|
+
});
|
|
311
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
312
|
+
await client.createMonitor({
|
|
313
|
+
url: "https://x.example.com",
|
|
314
|
+
name: "x",
|
|
315
|
+
emailAlerts: true,
|
|
316
|
+
policyId: "pol-7",
|
|
317
|
+
checkFrequency: 30,
|
|
318
|
+
});
|
|
319
|
+
const body = JSON.parse(calls[0].body);
|
|
320
|
+
assert.equal(body.policy_id, "pol-7");
|
|
321
|
+
assert.equal(typeof body.email, "boolean");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("no payload ever carries an address in `email`, even from a legacy alertEmail config", async () => {
|
|
325
|
+
const { fetchImpl, calls } = fakeFetch({
|
|
326
|
+
"POST https://uptime.betterstack.com/api/v2/monitors": { status: 201, body: { data: { id: "9" } } },
|
|
327
|
+
});
|
|
328
|
+
const config = parseMonitorConfig([{ url: "https://x.example.com", alertEmail: "oncall@example.com" }]);
|
|
329
|
+
const client = createBetterStackClient({ token: "tok", fetchImpl });
|
|
330
|
+
await applyMonitorConfig({ config, client: { ...client, listMonitors: async () => [] }, dryRun: false });
|
|
331
|
+
const body = JSON.parse(calls[0].body);
|
|
332
|
+
assert.equal(typeof body.email, "boolean");
|
|
333
|
+
assert.ok(
|
|
334
|
+
!JSON.stringify(body).includes("oncall@example.com"),
|
|
335
|
+
"the retired address never reaches the Better Stack payload"
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a converged monitor reports unchanged and issues no update (no per-run PATCH churn)", async () => {
|
|
340
|
+
// Live read-back carries the boolean `email`; desired carries the boolean
|
|
341
|
+
// `emailAlerts`. Pre-#403 these were a boolean vs an address, so every
|
|
342
|
+
// monitor read as drifted on every apply.
|
|
343
|
+
const client = fakeClient({
|
|
344
|
+
live: [
|
|
345
|
+
{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30, emailAlerts: true, policyId: "pol-1" },
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
const result = await applyMonitorConfig({
|
|
349
|
+
config: {
|
|
350
|
+
monitors: [
|
|
351
|
+
{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: "pol-1", checkFrequency: 30 },
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
client,
|
|
355
|
+
dryRun: false,
|
|
356
|
+
});
|
|
357
|
+
assert.deepEqual(result.unchanged, ["https://x.example.com"]);
|
|
358
|
+
assert.equal(result.updated.length, 0);
|
|
359
|
+
assert.equal(client.updated.length, 0, "a converged config issues zero update calls");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a drifted emailAlerts switch is still detected as an update", async () => {
|
|
363
|
+
const client = fakeClient({
|
|
364
|
+
live: [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30, emailAlerts: false }],
|
|
365
|
+
});
|
|
366
|
+
const result = await applyMonitorConfig({
|
|
367
|
+
config: {
|
|
368
|
+
monitors: [{ url: "https://x.example.com", name: "x", emailAlerts: true, policyId: null, checkFrequency: 30 }],
|
|
369
|
+
},
|
|
236
370
|
client,
|
|
237
371
|
dryRun: false,
|
|
238
|
-
defaultAlertEmail: "oncall@example.com",
|
|
239
372
|
});
|
|
240
|
-
assert.
|
|
373
|
+
assert.deepEqual(result.updated, ["https://x.example.com"]);
|
|
241
374
|
});
|
|
242
375
|
|
|
243
376
|
test("applyMonitorConfig issues update for a drifted existing monitor", async () => {
|
|
244
377
|
const client = fakeClient({ live: [{ id: "1", url: "https://x.example.com", name: "old", checkFrequency: 30 }] });
|
|
245
378
|
const result = await applyMonitorConfig({
|
|
246
|
-
config: { monitors: [{ url: "https://x.example.com", name: "new",
|
|
379
|
+
config: { monitors: [{ url: "https://x.example.com", name: "new", emailAlerts: true, policyId: null, checkFrequency: 30 }] },
|
|
247
380
|
client,
|
|
248
381
|
dryRun: false,
|
|
249
382
|
});
|
|
@@ -269,6 +402,50 @@ test("CLI skip-with-notice: no BETTERSTACK_API_TOKEN exits 0 with a notice, no c
|
|
|
269
402
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
270
403
|
});
|
|
271
404
|
|
|
405
|
+
test("CLI skip-with-notice raises a ::warning:: annotation so an inert apply is visible in the checks UI", () => {
|
|
406
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
407
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
408
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
|
|
409
|
+
const out = execFileSync("node", [CLI, "--config", configPath], {
|
|
410
|
+
encoding: "utf8",
|
|
411
|
+
env: { ...process.env, BETTERSTACK_API_TOKEN: "" },
|
|
412
|
+
});
|
|
413
|
+
assert.match(out, /^::warning title=Uptime monitors not applied::/m);
|
|
414
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("CLI announces UPTIME_ALERT_EMAIL as ignored instead of silently no-oping, and still exits 0", async () => {
|
|
418
|
+
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
419
|
+
const configPath = join(tmpDir, "monitors.json");
|
|
420
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
|
|
421
|
+
// "Secret provisioned, token absent" is the exact state that produced a
|
|
422
|
+
// permanently-green apply a consumer believed was routing alerts — so this
|
|
423
|
+
// run must still say the address is inert, and must still exit 0.
|
|
424
|
+
const { stdout, stderr } = await execFileAsync(
|
|
425
|
+
"node",
|
|
426
|
+
[CLI, "--config", configPath, "--alert-email", "oncall@example.com"],
|
|
427
|
+
{ env: { ...process.env, BETTERSTACK_API_TOKEN: "" } }
|
|
428
|
+
);
|
|
429
|
+
assert.match(stderr, /DEPRECATED: --alert-email \/ \$UPTIME_ALERT_EMAIL is ignored/);
|
|
430
|
+
assert.match(stderr, /policyId/, "the notice names the replacement, not just the removal");
|
|
431
|
+
assert.match(stdout, /^::warning title=UPTIME_ALERT_EMAIL is ignored::/m);
|
|
432
|
+
assert.match(stdout, /skipping uptime-monitor apply/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("CLI reports a config's retired alertEmail on stderr and never sends it", async () => {
|
|
436
|
+
const configPath = join(tmpDir, "legacy.json");
|
|
437
|
+
writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com", alertEmail: "oncall@example.com" }]));
|
|
438
|
+
const { stdout, stderr } = await execFileAsync(
|
|
439
|
+
"node",
|
|
440
|
+
[CLI, "--config", configPath, "--dry-run", "--alert-email", "oncall@example.com"],
|
|
441
|
+
{ env: { ...process.env, BETTERSTACK_API_TOKEN: "tok", BETTERSTACK_API_BASE_OVERRIDE: "http://127.0.0.1:9" } }
|
|
442
|
+
).catch((err) => err);
|
|
443
|
+
assert.match(stderr, /DEPRECATED: monitor config entry \[0\] sets "alertEmail"/);
|
|
444
|
+
assert.match(stderr, /DEPRECATED: --alert-email \/ \$UPTIME_ALERT_EMAIL is ignored/);
|
|
445
|
+
assert.match(stdout, /::warning title=UPTIME_ALERT_EMAIL is ignored::/);
|
|
446
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
447
|
+
});
|
|
448
|
+
|
|
272
449
|
test("CLI exits non-zero on an invalid config file even with a token set", () => {
|
|
273
450
|
tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
|
|
274
451
|
const configPath = join(tmpDir, "monitors.json");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `scripts/resolve-diff-range.sh` has solid unit coverage
|
|
8
8
|
* (`resolve-diff-range.test.mjs`), but the affected-mode *wiring* inside the
|
|
9
9
|
* reusable workflow was only covered indirectly by anchor-expansion checks.
|
|
10
|
-
* This suite pins the
|
|
10
|
+
* This suite pins the four invariants that keep the affected-mode design
|
|
11
11
|
* correct, modelled on the sibling `check-ci-required-aggregator.test.mjs`
|
|
12
12
|
* (read the real workflow, extract blocks by indentation, assert):
|
|
13
13
|
*
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* `affected-base` override is rejected before the `$GITHUB_ENV` write, so
|
|
25
25
|
* it can never inject a second env line; a single-line override exports
|
|
26
26
|
* exactly `TURBO_SCM_BASE` / `TURBO_SCM_HEAD` and nothing else.
|
|
27
|
+
* 4. DOCUMENTED PRECONDITION (Story #413) — neither `.github/workflows/
|
|
28
|
+
* pr-quality.yml` nor `docs/reusable-workflows.md` claims affected mode
|
|
29
|
+
* can never miss a task (true only of an UNRESOLVABLE base), and both
|
|
30
|
+
* give the conditional consumer shape `turbo run <tier>
|
|
31
|
+
* ${TURBO_SCM_BASE:+--affected}` — the gate that stops a baked-in
|
|
32
|
+
* `--affected` from scheduling zero tasks on a push-to-`main` run.
|
|
27
33
|
*
|
|
28
34
|
* The `run:` script is executed against real bash with a stubbed
|
|
29
35
|
* `resolve-diff-range.sh` (the sourced derivation), so no git repo is needed;
|
|
@@ -180,3 +186,50 @@ test("single-line affected-base override exports exactly TURBO_SCM_BASE and TURB
|
|
|
180
186
|
const nonEmpty = r.envLines.split("\n").filter((l) => l.trim() !== "");
|
|
181
187
|
assert.equal(nonEmpty.length, 2, `expected exactly two env lines, got: ${JSON.stringify(nonEmpty)}`);
|
|
182
188
|
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// 4. DOCUMENTED PRECONDITION (Story #413) — the `affected` input's safety claim
|
|
192
|
+
//
|
|
193
|
+
// The old prose promised affected mode "can never miss a task", which is true
|
|
194
|
+
// only of the UNRESOLVABLE-base case. A `--affected` baked unconditionally into
|
|
195
|
+
// a consumer's package script hits the resolvable-but-EMPTY case instead: with
|
|
196
|
+
// `affected: false` the TURBO_SCM_* vars are never exported, turbo falls back
|
|
197
|
+
// to its own default base of `main`, and on a push to `main` that range is
|
|
198
|
+
// empty — zero tasks scheduled, exit 0, a silent no-op that reads as a pass.
|
|
199
|
+
// These assertions pin the correction in both consumer-facing surfaces so the
|
|
200
|
+
// absolute claim cannot return and the conditional shape cannot be dropped.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
204
|
+
|
|
205
|
+
/** The safe consumer shape: `--affected` only when a base was exported. */
|
|
206
|
+
const CONDITIONAL_FORM = "${TURBO_SCM_BASE:+--affected}";
|
|
207
|
+
|
|
208
|
+
/** Absolute claims that over-promise the fallback. Retired — must not return. */
|
|
209
|
+
const ABSOLUTE_CLAIMS = ["so this can never miss a task", "never a missed task"];
|
|
210
|
+
|
|
211
|
+
for (const [label, relPath] of [
|
|
212
|
+
["the reusable workflow", WORKFLOW],
|
|
213
|
+
["the consumer documentation", DOCS],
|
|
214
|
+
]) {
|
|
215
|
+
test(`${label} states the affected-mode precondition conditionally, not absolutely`, () => {
|
|
216
|
+
const text = readFileSync(join(repoRoot, relPath), "utf8");
|
|
217
|
+
// Case-insensitive: the claim is retired as a claim, not as a casing.
|
|
218
|
+
const haystack = text.toLowerCase();
|
|
219
|
+
|
|
220
|
+
for (const claim of ABSOLUTE_CLAIMS) {
|
|
221
|
+
assert.ok(
|
|
222
|
+
!haystack.includes(claim),
|
|
223
|
+
`${relPath} still claims "${claim}" — the full-run fallback covers an ` +
|
|
224
|
+
"unresolvable base only, not a base that resolves to an empty range"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
assert.ok(
|
|
229
|
+
text.includes(CONDITIONAL_FORM),
|
|
230
|
+
`${relPath} must recommend \`turbo run <tier> ${CONDITIONAL_FORM}\` — gating ` +
|
|
231
|
+
"the flag on the exported base is what keeps a push-to-`main` run from " +
|
|
232
|
+
"silently scheduling zero tasks"
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -485,7 +485,7 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
|
|
|
485
485
|
//
|
|
486
486
|
// The workflow's "Coverage threshold gate" step no longer embeds a copy of
|
|
487
487
|
// this script (Story #230): it sparse-side-checkouts mandrel-platform at
|
|
488
|
-
// `
|
|
488
|
+
// `job.workflow_sha` into `_mandrel-platform-scripts/` and runs
|
|
489
489
|
// `scripts/check-coverage-threshold.mjs` directly. These tests exercise that
|
|
490
490
|
// exact invocation shape — the real script, run from the side-checkout path,
|
|
491
491
|
// with the workflow's `--threshold` / `--metric` args and the consumer
|
|
@@ -84,6 +84,8 @@
|
|
|
84
84
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
85
85
|
import { resolve } from "node:path";
|
|
86
86
|
|
|
87
|
+
import { isDirectInvocation } from './lib/entry-guard.mjs';
|
|
88
|
+
|
|
87
89
|
// The override acknowledgement label. Documented in docs/reusable-workflows.md.
|
|
88
90
|
export const DEFAULT_OVERRIDE_LABEL = "migration:destructive-ok";
|
|
89
91
|
|
|
@@ -635,7 +637,9 @@ function main() {
|
|
|
635
637
|
process.exit(1);
|
|
636
638
|
}
|
|
637
639
|
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
+
// Direct-invocation guard — symlink-safe via the shared seam (Story #407):
|
|
641
|
+
// comparing an unresolved argv[1] against a realpath-resolved
|
|
642
|
+
// import.meta.url silently never matches under pnpm's symlinked node_modules.
|
|
643
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
640
644
|
main();
|
|
641
645
|
}
|