mandrel-platform 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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": {
@@ -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 Default alert-contact email applied to any monitor
47
- * entry that does not set its own `alertEmail`. Defaults
48
- * to $UPTIME_ALERT_EMAIL.
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).
@@ -71,12 +74,24 @@ import { resolve } from "node:path";
71
74
  * {
72
75
  * "url": "https://api.example.com/health", // required, http(s) URL
73
76
  * "name": "api", // optional, defaults to url's host
74
- * "alertEmail": "oncall@example.com", // optional, falls back to --alert-email
77
+ * "emailAlerts": true, // optional, boolean, default true
78
+ * "policyId": "12345", // optional, Better Stack escalation policy
75
79
  * "checkFrequency": 30 // optional, seconds, default 30
76
80
  * }
77
81
  *
82
+ * `emailAlerts` maps to Better Stack's `email` field, which the Monitors API
83
+ * types as a **boolean** ("Send e-mail alerts.") — it is a switch, not a
84
+ * recipient. Recipients resolve from the Better Stack team roster and the
85
+ * escalation policy named by `policyId` (`policy_id`), never from a
86
+ * per-monitor address. The retired `alertEmail` field is still accepted and
87
+ * type-checked so an existing config keeps parsing, but it is inert: it is
88
+ * reported in `deprecations[]` and never reaches the API payload.
89
+ *
78
90
  * @param {unknown} raw Parsed JSON.
79
- * @returns {{ monitors: Array<{url:string, name:string, alertEmail:string|null, checkFrequency:number}> }}
91
+ * @returns {{
92
+ * monitors: Array<{url:string, name:string, emailAlerts:boolean, policyId:string|null, checkFrequency:number}>,
93
+ * deprecations: string[]
94
+ * }}
80
95
  * @throws {Error} with a message naming the offending index/field on invalid input.
81
96
  */
82
97
  export function parseMonitorConfig(raw) {
@@ -86,9 +101,18 @@ export function parseMonitorConfig(raw) {
86
101
  "monitor config must be a JSON array of monitor entries, or an object with a `monitors` array."
87
102
  );
88
103
  }
89
- return {
90
- monitors: list.map((entry, i) => validateMonitorEntry(entry, i)),
91
- };
104
+ const deprecations = [];
105
+ const monitors = list.map((entry, i) => {
106
+ if (entry && typeof entry === "object" && entry.alertEmail !== undefined) {
107
+ deprecations.push(
108
+ `monitor config entry [${i}] sets "alertEmail" — that field is ignored. ` +
109
+ `Better Stack types the monitor's \`email\` field as a boolean (an on/off switch), not a recipient. ` +
110
+ `Use "emailAlerts" to toggle e-mail alerts and "policyId" to choose the escalation policy that decides who is alerted.`
111
+ );
112
+ }
113
+ return validateMonitorEntry(entry, i);
114
+ });
115
+ return { monitors, deprecations };
92
116
  }
93
117
 
94
118
  function validateMonitorEntry(entry, index) {
@@ -110,13 +134,20 @@ function validateMonitorEntry(entry, index) {
110
134
  if (entry.alertEmail !== undefined && typeof entry.alertEmail !== "string") {
111
135
  throw new Error(`monitor config entry [${index}] "alertEmail" must be a string when present.`);
112
136
  }
137
+ if (entry.emailAlerts !== undefined && typeof entry.emailAlerts !== "boolean") {
138
+ throw new Error(`monitor config entry [${index}] "emailAlerts" must be a boolean when present.`);
139
+ }
140
+ if (entry.policyId !== undefined && typeof entry.policyId !== "string") {
141
+ throw new Error(`monitor config entry [${index}] "policyId" must be a string when present.`);
142
+ }
113
143
  if (entry.checkFrequency !== undefined && !(Number.isInteger(entry.checkFrequency) && entry.checkFrequency > 0)) {
114
144
  throw new Error(`monitor config entry [${index}] "checkFrequency" must be a positive integer (seconds) when present.`);
115
145
  }
116
146
  return {
117
147
  url: entry.url,
118
148
  name: entry.name ?? host,
119
- alertEmail: entry.alertEmail ?? null,
149
+ emailAlerts: entry.emailAlerts ?? true,
150
+ policyId: entry.policyId ?? null,
120
151
  checkFrequency: entry.checkFrequency ?? DEFAULT_CHECK_FREQUENCY_SECONDS,
121
152
  };
122
153
  }
@@ -163,7 +194,13 @@ function normalizeUrl(url) {
163
194
  function monitorNeedsUpdate(live, desired) {
164
195
  if (live.name !== undefined && live.name !== desired.name) return true;
165
196
  if (live.checkFrequency !== undefined && live.checkFrequency !== desired.checkFrequency) return true;
166
- if (live.alertEmail !== undefined && desired.alertEmail !== null && live.alertEmail !== desired.alertEmail) return true;
197
+ // Both sides are booleans: the live read-back maps Better Stack's boolean
198
+ // `email` attribute, and `emailAlerts` normalizes to a boolean at parse.
199
+ // Comparing a boolean against a configured address (the pre-#403 shape)
200
+ // read as drift on every beat and issued a redundant PATCH per monitor
201
+ // per apply.
202
+ if (live.emailAlerts !== undefined && live.emailAlerts !== desired.emailAlerts) return true;
203
+ if (live.policyId !== undefined && desired.policyId !== null && live.policyId !== desired.policyId) return true;
167
204
  return false;
168
205
  }
169
206
 
@@ -209,7 +246,8 @@ export function createBetterStackClient({ token, fetchImpl = fetch, apiBase = BE
209
246
  url: m.attributes?.url ?? "",
210
247
  name: m.attributes?.pronounceable_name,
211
248
  checkFrequency: m.attributes?.check_frequency,
212
- alertEmail: m.attributes?.email,
249
+ emailAlerts: m.attributes?.email,
250
+ policyId: m.attributes?.policy_id,
213
251
  }));
214
252
  },
215
253
  async createMonitor(entry) {
@@ -231,7 +269,11 @@ function toBetterStackPayload(entry) {
231
269
  url: entry.url,
232
270
  pronounceable_name: entry.name,
233
271
  check_frequency: entry.checkFrequency,
234
- ...(entry.alertEmail ? { email: entry.alertEmail } : {}),
272
+ // `email` is a boolean in Better Stack's Monitors API ("Send e-mail
273
+ // alerts.") — always send the switch, never an address. `policy_id` is
274
+ // the field that actually determines who is alerted.
275
+ email: entry.emailAlerts,
276
+ ...(entry.policyId ? { policy_id: entry.policyId } : {}),
235
277
  };
236
278
  }
237
279
 
@@ -245,14 +287,10 @@ function toBetterStackPayload(entry) {
245
287
  * @param {{monitors: Array}} opts.config
246
288
  * @param {ReturnType<typeof createBetterStackClient>} opts.client
247
289
  * @param {boolean} opts.dryRun
248
- * @param {string|null} [opts.defaultAlertEmail]
249
290
  * @returns {Promise<{created: string[], updated: string[], unchanged: string[], dryRun: boolean}>}
250
291
  */
251
- export async function applyMonitorConfig({ config, client, dryRun, defaultAlertEmail = null }) {
252
- const desired = config.monitors.map((m) => ({
253
- ...m,
254
- alertEmail: m.alertEmail ?? defaultAlertEmail,
255
- }));
292
+ export async function applyMonitorConfig({ config, client, dryRun }) {
293
+ const desired = config.monitors;
256
294
  const live = await client.listMonitors();
257
295
  const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
258
296
 
@@ -322,10 +360,33 @@ async function main() {
322
360
  process.exit(1);
323
361
  }
324
362
 
363
+ // Announced before the token check on purpose: "secret provisioned, token
364
+ // absent" is exactly the state that produced a permanently-green apply a
365
+ // consumer believed was routing alerts, so that run is the one that most
366
+ // needs to hear the address is inert (refs #403).
367
+ if (opts.alertEmail) {
368
+ process.stdout.write(
369
+ "::warning title=UPTIME_ALERT_EMAIL is ignored::Better Stack resolves alert recipients from the team roster " +
370
+ "and escalation policy, not from a per-monitor address. Set a monitor's `policyId` instead, and drop this secret from your caller.\n"
371
+ );
372
+ process.stderr.write(
373
+ "[apply-uptime-monitors] DEPRECATED: --alert-email / $UPTIME_ALERT_EMAIL is ignored — Better Stack's monitor " +
374
+ "`email` field is a boolean switch, not a recipient. Use a monitor entry's `policyId` (escalation policy) to " +
375
+ "control who is alerted, and `emailAlerts` to toggle e-mail alerts.\n"
376
+ );
377
+ }
378
+
325
379
  // Graceful degradation: no token → skip-with-notice, exit 0. Preserves the
326
380
  // pre-existing per-consumer behaviour when Better Stack secrets are not
327
381
  // yet provisioned (acceptance criterion — see docs/reusable-workflows.md).
328
382
  if (!opts.token) {
383
+ // Annotation-level, not stdout-only: a consumer can otherwise sit on a
384
+ // permanently-green `uptime-apply` for weeks with zero live monitors and
385
+ // never notice the apply is inert (refs #403).
386
+ process.stdout.write(
387
+ "::warning title=Uptime monitors not applied::BETTERSTACK_API_TOKEN is not provisioned — " +
388
+ "this uptime-apply run created and updated nothing. Provision the secret to activate uptime monitoring.\n"
389
+ );
329
390
  process.stdout.write(
330
391
  "⏭️ apply-uptime-monitors: BETTERSTACK_API_TOKEN not provided — skipping uptime-monitor apply (Better Stack not provisioned for this consumer yet).\n"
331
392
  );
@@ -341,6 +402,10 @@ async function main() {
341
402
  process.exit(1);
342
403
  }
343
404
 
405
+ for (const notice of config.deprecations) {
406
+ process.stderr.write(`[apply-uptime-monitors] DEPRECATED: ${notice}\n`);
407
+ }
408
+
344
409
  // Test-only escape hatch: point the CLI at a local/offline server instead
345
410
  // of the live Better Stack API. Never set in a production caller — see
346
411
  // createBetterStackClient's apiBase docblock.
@@ -355,7 +420,6 @@ async function main() {
355
420
  config,
356
421
  client,
357
422
  dryRun: opts.dryRun,
358
- defaultAlertEmail: opts.alertEmail,
359
423
  });
360
424
  const verb = result.dryRun ? "would create" : "created";
361
425
  const verbUpdate = result.dryRun ? "would update" : "updated";
@@ -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].alertEmail, null);
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: [{ url: "https://x.example.com", name: "x", alertEmail: "a@b.com", checkFrequency: 60 }],
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", alertEmail: "a@b.com", checkFrequency: 60 },
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", alertEmail: null, checkFrequency: 30 }];
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", alertEmail: null, checkFrequency: 30 }];
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", alertEmail: null, checkFrequency: 60 }];
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", alertEmail: null, checkFrequency: 30 }];
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", alertEmail: null, checkFrequency: 30 }];
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
- attributes: { url: "https://a.example.com", pronounceable_name: "a", check_frequency: 30, email: "a@b.com" },
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, alertEmail: "a@b.com" },
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", alertEmail: null, checkFrequency: 30 }] },
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", alertEmail: null, checkFrequency: 30 }] },
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("applyMonitorConfig falls back to defaultAlertEmail when an entry sets none", async () => {
233
- const client = fakeClient({ live: [] });
234
- await applyMonitorConfig({
235
- config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
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.equal(client.created[0].alertEmail, "oncall@example.com");
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", alertEmail: null, checkFrequency: 30 }] },
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");
@@ -46,9 +46,15 @@ jobs:
46
46
  # the checked-in config; a workflow_dispatch preview run can pass
47
47
  # apply:'false' instead to dry-run without writing.
48
48
  apply: ${{ github.event_name == 'push' && 'true' || 'false' }}
49
- # Frozen secret surface: only these two cross into the shared workflow.
50
- # Both are optional on the shared side an absent BETTERSTACK_API_TOKEN
51
- # is the documented graceful-degradation (skip-with-notice) path.
49
+ # Frozen secret surface. BETTERSTACK_API_TOKEN is optional on the shared
50
+ # side absent, the apply takes the documented graceful-degradation
51
+ # (skip-with-notice) path and raises a warning annotation.
52
+ #
53
+ # UPTIME_ALERT_EMAIL is deprecated and deliberately not passed here:
54
+ # Better Stack's monitor `email` field is a boolean switch, not a
55
+ # recipient, so the address never routed alerts. Set a monitor entry's
56
+ # `policyId` (escalation policy) to control who is alerted. The shared
57
+ # workflow still declares the secret, so an existing caller that passes
58
+ # it keeps compiling.
52
59
  secrets:
53
60
  BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
54
- UPTIME_ALERT_EMAIL: ${{ secrets.UPTIME_ALERT_EMAIL }}