From 2b739853eb98f2c60d37c1d3555db8db81ec92ae Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 10:51:51 -0400 Subject: [PATCH 1/8] docs(alerts): design spec for deploy lifecycle alerts (start/end/fail) --- ...26-08-04-deploy-lifecycle-alerts-design.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md diff --git a/docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md b/docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md new file mode 100644 index 000000000..027f93c7a --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md @@ -0,0 +1,137 @@ +# Design: Deploy lifecycle alerts (start / end / fail) via `cds-alert-notification` + +**Date:** 2026-08-04 +**Status:** Approved (design), pending spec review → implementation plan +**Repo:** tutorials-ims (local `tutorials-poc`) +**Builds on:** [2026-08-03-ans-integration-tutorials-ims-design.md](2026-08-03-ans-integration-tutorials-ims-design.md) — the ANS/`cds-alert-notification` integration wired yesterday +**Plugin:** `@sap-tutorials/cds-alert-notification` v1.0.0 (already a dependency; `cds.requires.alerts` already configured) + +--- + +## 1. Goal + +Automatically notify the team when a deploy **starts**, **finishes**, or **fails**, reusing the ANS alerting plumbing already in place. Today a deploy is silent — the operator watches the terminal and nobody else knows. This adds a push signal at each deploy lifecycle boundary. + +## 2. Why an endpoint (not a direct ANS call from the deploy script) + +The Alert Notification credentials are bound to the **`tutorials-srv` CF app** (VCAP service binding). The deploy orchestrator (`scripts/deploy-mta.cjs`) runs on an operator workstation or CI runner — it has **no** ANS binding and cannot call the service directly. So the srv (which holds the binding) exposes an endpoint; the deploy script pings it. + +Timing is sound: the **start** ping hits the *old* running srv instance; the **end** ping hits the freshly-restarted *new* instance. Both are live at call time. + +## 3. Scope + +**In:** +1. New Express route `POST /ops/deploy-event` on the srv, guarded by the existing `contentAuthMiddleware` (reuses `CONTENT_API_KEY`). Maps a `phase` to an `alerting.raise(...)` call. Fail-open. +2. `scripts/deploy-mta.cjs` fires the endpoint at three existing phase boundaries — **all envs** (dev/qa/prod), each targeting its own `srvUrl` from the ENV table. +3. `package.json` `cds.requires.alerts` — register the three new `eventTypes`, add one dedicated deploy channel + one route. +4. `.deploy/mta.yaml` — no new resource (the ANS resource already exists from yesterday); only the new channel's email **action** is provisioned operator-side. + +**Out (YAGNI / operator-owned):** +- The real deploy-channel email address (bound in the ANS action at provisioning, not in code — same pattern as `devrel-oncall`). +- Flipping `ChatSettings.alertsEnabled` ON in each env (operator, via `/admin-ui`). +- Alerting on deploys triggered by paths *other* than `deploy-mta.cjs` (e.g. a raw `cf deploy`, or CI's `deploy.yml` if it bypasses this script — a follow-up can add the same three pings there). +- Blue-green nuance beyond §7. + +## 4. Endpoint contract — `POST /ops/deploy-event` + +Auth: `Authorization: Bearer ` via `contentAuthMiddleware` +(503 if key unset, 401 missing bearer, 403 wrong key — inherited behavior). + +Request body (`application/json`): + +```jsonc +{ + "phase": "start" | "end" | "fail", // required + "env": "dev" | "qa" | "prod", // required — for subject/resourceName + "version": "1.42.3", // optional — MTA version being deployed + "detail": "smoke gate failed" // optional — free text, used on fail +} +``` + +Response: **always `202 Accepted`** with `{ ok: true }` on a well-formed request, **regardless of whether the alert actually raised** — alerting is fail-open and must never block or fail a deploy. `400` only for a malformed/absent `phase`. The handler `void`s the `alerting.raise(...)` (does not await it into the response path), matching the existing call sites. + +Phase → payload mapping: + +| phase | eventType | severity | subject | +|---------|------------------|----------|--------------------------------------| +| `start` | `DeployStarted` | `NOTICE` | `Deploy started — ` | +| `end` | `DeployFinished` | `NOTICE` | `Deploy finished — ` | +| `fail` | `DeployFailed` | `ERROR` | `Deploy FAILED — ` | + +All three set `category: 'ALERT'`, `resource: { resourceName: 'deploy-', resourceType: 'deployment' }`, and `body` = `detail` (or a default). `resourceName` includes `` so ANS dedup keys (`eventType:resourceName`) don't collapse a dev and prod deploy of the same phase within the 5-min window. + +## 5. Routing / channel (severity-threshold model) + +The plugin routes **only by severity threshold** (`resolveChannels(severity, cfg)` in `lib/routing.js`): an event is delivered to every route whose `minSeverity` it meets. There is **no** eventType-based routing and no per-`raise()` channel override. The ANS severity scale is `INFO / NOTICE / WARNING / ERROR / FATAL`. + +> Note: the `Information / Success / Warning / Error` enum in `srv/lib/alert-enums.js` is the **visitor-banner** (`Alerts` entity) code list — unrelated to ANS severities. Do not conflate them. + +Config (`package.json` `cds.requires.alerts`), extending yesterday's block: + +```jsonc +"channels": [ + "email:devrel-oncall", + "email:devrel-deploys" // NEW — dedicated deploy channel +], +"routes": [ + { "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }, + { "minSeverity": "NOTICE", "channels": ["email:devrel-deploys"] } // NEW +], +"eventTypes": [ + "PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed", + "DeployStarted", "DeployFinished", "DeployFailed" // NEW +] +``` + +Resulting delivery (**Option A**, chosen): +- `DeployStarted` / `DeployFinished` (NOTICE) → `email:devrel-deploys` **only**. +- `DeployFailed` (ERROR) → `email:devrel-deploys` **and** `email:devrel-oncall` (a failed deploy meets both thresholds — on-call *should* hear it). + +`devrel-deploys` is a named channel; its real distribution-list address is bound in the ANS email **action** at provisioning (cockpit / `provision.sh`), exactly like `devrel-oncall`. + +## 6. Deploy-script integration (`scripts/deploy-mta.cjs`) + +A single helper `notifyDeploy(phase, cfg, extra)` that POSTs to `${cfg.srvUrl}/ops/deploy-event` with the bearer token from `process.env.CONTENT_API_KEY`, using native `fetch` with a short `AbortController` timeout (~5s). It is **fully best-effort**: any failure (no key, network error, non-2xx, timeout) is caught and logged as a `warn(...)` line — it NEVER calls `die()` and never changes the deploy exit code. + +Fire points (mapping to the script's existing numbered steps): +- **start** — at the top of **Step 4**, immediately before `cf deploy` runs (real deploys only; skipped under `--dry-run`). +- **end** — after **Step 5**'s smoke gate passes (`ok('smoke tests passed…')` path). For **blue-green**, see §7. +- **fail** — on the `cf deploy` failure path (before/around `abortFailedBlueGreen()`), and on the smoke-gate failure path (before `process.exit(2)`). Passes `detail` describing which gate failed. + +Guards: no ping under `--dry-run`. If `CONTENT_API_KEY` is absent from the operator env, `notifyDeploy` logs one `warn` and returns (deploy proceeds normally). Fires for **all three envs** — each posts to its own `srvUrl`. + +## 7. Blue-green nuance + +In `--strategy blue-green`, Step 4 brings up green apps then **pauses** before the traffic swap, and Step 5's automatic smoke gate is intentionally skipped (public routes still serve blue). So for blue-green: +- **start** fires normally before Step 4. +- **end** does NOT fire automatically (the script exits paused, pre-swap). The operator resumes the swap by hand later. v1 accepts this — a blue-green deploy simply won't emit an automatic "finished". (A later enhancement could ping "end" from the resume path, but that's outside this script's single invocation.) The paused-exit branch logs a `warn` noting no `end` alert will fire. +- **fail** still fires on a failed green bring-up. + +This limitation is documented in the script's Step 5 blue-green branch and in the runbook note. + +## 8. Error handling + +- Endpoint: malformed `phase` → 400; everything else → 202. `alerting.raise` is already fail-open (never throws). The DB gate (`ChatSettings.alertsEnabled`, default OFF) still applies — if alerting is disabled, the endpoint still returns 202 but nothing is delivered. **Accepted edge case (confirmed by Tom):** a deploy that flips `alertsEnabled` can suppress its own `end`/`fail` ping. +- Deploy script: `notifyDeploy` swallows all errors → deploy behavior is byte-identical to today when alerting is down or misconfigured. + +## 9. Testing + +- **Unit** (`srv/lib/__tests__/` or `srv/routes/__tests__/`): mount the route on a bare Express app, stub `alerting.raise`; assert (a) 202 + `raise` called with the right eventType/severity per phase, (b) 400 on missing phase, (c) 401/403/503 auth behavior via `contentAuthMiddleware`, (d) 202 even when `raise` rejects (fail-open). Reuse the memory-sink pattern from yesterday's ANS tests where an end-to-end assert is wanted. +- **Deploy script**: `notifyDeploy` extracted as a testable pure-ish function (inject `fetch` + logger) — assert it never throws on network failure and never affects exit code. Guard: `--dry-run` sends nothing. +- No smoke-test change required (the endpoint is ops-internal, not user-facing). + +## 10. Files touched + +| File | Change | +|------|--------| +| `srv/routes/deploy-events.js` | **new** — the route + handler | +| `srv/server.js` | register the route (beside the other `/content/*` bearer routes) | +| `package.json` | add channel, route, 3 eventTypes to `cds.requires.alerts` | +| `scripts/deploy-mta.cjs` | `notifyDeploy` helper + 3 fire points | +| `srv/routes/__tests__/deploy-events.test.js` | **new** — unit tests | +| `.deploy/mta.yaml` | **minor** version bump (feature); no new resource | +| runbook (`docs/developers/operations/mta-deployment.md`) | note the new alerts + blue-green caveat + the `devrel-deploys` action provisioning step | + +## 11. `srv-qa` cp-list check + +`srv/routes/deploy-events.js` imports only `srv/lib/alerting.js` (already shipped) and no new `srv/lib/` transitive deps, so the `.deploy/mta.yaml` `srv-qa` `cp` list needs no change. **Verify during implementation** per the project rule (re-walk `./` imports). From 625066361df57e4f014c5fb7196f08dd1100e3cf Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 10:57:47 -0400 Subject: [PATCH 2/8] docs(alerts): implementation plan for deploy lifecycle alerts --- .../2026-08-04-deploy-lifecycle-alerts.md | 607 ++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-deploy-lifecycle-alerts.md diff --git a/docs/superpowers/plans/2026-08-04-deploy-lifecycle-alerts.md b/docs/superpowers/plans/2026-08-04-deploy-lifecycle-alerts.md new file mode 100644 index 000000000..ad2ec9d8c --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-deploy-lifecycle-alerts.md @@ -0,0 +1,607 @@ +# Deploy Lifecycle Alerts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Notify the team via SAP Alert Notification when a deploy starts, finishes, or fails, by adding a bearer-guarded srv endpoint that the deploy orchestrator pings at each lifecycle boundary. + +**Architecture:** The ANS credentials are bound to the `tutorials-srv` CF app, but `scripts/deploy-mta.cjs` runs off-platform. So the srv exposes `POST /ops/deploy-event` (guarded by the existing `CONTENT_API_KEY` middleware); the deploy script best-effort-pings it before `cf deploy` (start), after the smoke gate passes (end), and on failure paths (fail). The endpoint maps `phase` → `alerting.raise(...)`, reusing yesterday's fail-open `srv/lib/alerting.js`. Routing is severity-threshold based: NOTICE deploy chatter → a new dedicated `email:devrel-deploys` channel; ERROR failures additionally hit on-call. + +**Tech Stack:** Node.js ESM (`@sap/cds`), Express, `@sap-tutorials/cds-alert-notification`, Vitest, native `fetch`, CommonJS deploy script. + +## Global Constraints + +- **Spec:** `docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md`. +- **Alerting is fail-open, always** — `alerting.raise(...)` never throws; the endpoint returns 202 regardless of delivery; the deploy script NEVER changes its exit code because of a ping. Copy the `void alerting.raise({...})` (non-awaited) idiom from existing call sites. +- **Auth reuses `CONTENT_API_KEY`** via `contentAuthMiddleware` (exported from `srv/lib/content-store.js`): 503 when key unset, 401 missing bearer, 403 wrong key (timing-safe). +- **ANS severity scale** is `INFO / NOTICE / WARNING / ERROR / FATAL`. Do NOT use the `Information/Success/...` values from `srv/lib/alert-enums.js` — that is the unrelated visitor-banner code list. +- **`raise()` payload shape:** `{ eventType, severity, category, subject, body, resource: { resourceName, resourceType } }` (matches `srv/jobs/scheduler.js:176`). +- **No new npm deps** — native `fetch` only (project rule: prefer built-in fetch). +- **Windows/CRLF:** author new files with LF endings. +- **MTA version bump = minor** (feature) in `.deploy/mta.yaml` only (root mta.yaml is legacy). +- **Tests:** no supertest — spin an ephemeral server with `http.createServer` + native `fetch`, per `srv/lib/__tests__/alerts-endpoint.test.js`. +- Work happens on branch `worktree-deploy-lifecycle-alerts`; commit after each task. + +--- + +### Task 1: `cds.requires.alerts` config — channel, route, eventTypes + +**Files:** +- Modify: `package.json` (`cds.requires.alerts` block) +- Test: `srv/lib/__tests__/deploy-alerts-config.test.js` (create) + +**Interfaces:** +- Consumes: existing `cds.requires.alerts` block (from yesterday's ANS integration). +- Produces: the config guarantees delivery routing that Task 2's severities rely on — `DeployStarted`/`DeployFinished` at `NOTICE` reach `email:devrel-deploys`; `DeployFailed` at `ERROR` reaches both `email:devrel-deploys` and `email:devrel-oncall`. + +- [ ] **Step 1: Write the failing test** + +```js +// srv/lib/__tests__/deploy-alerts-config.test.js +import { describe, it, expect } from 'vitest'; +import { resolveChannels } from '@sap-tutorials/cds-alert-notification/lib/routing.js'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); +const cfg = pkg.cds.requires.alerts; + +describe('deploy alerts routing config', () => { + it('declares the dedicated deploy channel', () => { + expect(cfg.channels).toContain('email:devrel-deploys'); + }); + it('registers the three deploy eventTypes', () => { + for (const t of ['DeployStarted', 'DeployFinished', 'DeployFailed']) { + expect(cfg.eventTypes).toContain(t); + } + }); + it('routes NOTICE-level deploy chatter to the deploys channel only', () => { + const ch = resolveChannels('NOTICE', cfg); + expect(ch).toContain('email:devrel-deploys'); + expect(ch).not.toContain('email:devrel-oncall'); + }); + it('routes ERROR-level failures to BOTH deploys and on-call', () => { + const ch = resolveChannels('ERROR', cfg); + expect(ch).toContain('email:devrel-deploys'); + expect(ch).toContain('email:devrel-oncall'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run srv/lib/__tests__/deploy-alerts-config.test.js` +Expected: FAIL — `channels` lacks `email:devrel-deploys`, NOTICE route absent. + +- [ ] **Step 3: Edit the config** + +In `package.json`, under `cds.requires.alerts`, extend the three arrays (leave `kind`/profiles/`dedupWindowMs` untouched): + +```jsonc +"channels": [ + "email:devrel-oncall", + "email:devrel-deploys" +], +"routes": [ + { "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }, + { "minSeverity": "NOTICE", "channels": ["email:devrel-deploys"] } +], +"eventTypes": [ + "PublishRejected", + "ScheduledJobFailed", + "RebuildDispatchFailed", + "DeployStarted", + "DeployFinished", + "DeployFailed" +] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run srv/lib/__tests__/deploy-alerts-config.test.js` +Expected: PASS (4 tests). + +- [ ] **Step 5: Validate the plugin build accepts the config** + +Run: `npx cds build --production 2>&1 | grep -iE "alert|channel|route|error" | head -20` +Expected: no `route references undefined channel` / `unknown severity` / `unknown type` errors (the plugin `cds build` step fails hard on bad routing config). If the full build is slow, this still surfaces alert-config validation early. + +- [ ] **Step 6: Commit** + +```bash +git add package.json srv/lib/__tests__/deploy-alerts-config.test.js +git commit -m "feat(alerts): add deploy eventTypes + dedicated deploys channel/route" +``` + +--- + +### Task 2: `POST /ops/deploy-event` route + handler + +**Files:** +- Create: `srv/routes/deploy-events.js` +- Test: `srv/routes/__tests__/deploy-events.test.js` (create) + +**Interfaces:** +- Consumes: `alerting.raise(input)` from `srv/lib/alerting.js` (fail-open, returns Promise); `contentAuthMiddleware` from `srv/lib/content-store.js` (wired by Task 3, NOT inside this module — this module stays auth-agnostic and testable). +- Produces: `export function register(app, { authMw } = {})` — mounts `POST /ops/deploy-event`. When `authMw` is provided it is applied before the handler; when omitted (unit tests) the handler runs unguarded. Also exports `phaseToPayload(phase, { env, version, detail })` → the `raise()` payload object (pure, unit-tested directly). + +- [ ] **Step 1: Write the failing test** + +```js +// srv/routes/__tests__/deploy-events.test.js +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'node:http'; +import express from 'express'; + +// Mock the fail-open alerting module so no ANS/DB is needed. +const raiseMock = vi.fn(() => Promise.resolve()); +vi.mock('../../lib/alerting.js', () => ({ raise: (...a) => raiseMock(...a) })); + +const { register, phaseToPayload } = await import('../deploy-events.js'); + +let server, baseUrl; +beforeAll(async () => { + const app = express(); + register(app); // no authMw → handler runs unguarded + server = http.createServer(app); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); +afterAll(async () => { await new Promise((r) => server?.close(r)); }); + +function post(body) { + return fetch(`${baseUrl}/ops/deploy-event`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('phaseToPayload', () => { + it('maps start → DeployStarted / NOTICE', () => { + const p = phaseToPayload('start', { env: 'prod', version: '1.2.3' }); + expect(p.eventType).toBe('DeployStarted'); + expect(p.severity).toBe('NOTICE'); + expect(p.resource).toEqual({ resourceName: 'deploy-prod', resourceType: 'deployment' }); + expect(p.subject).toContain('prod'); + expect(p.subject).toContain('1.2.3'); + }); + it('maps end → DeployFinished / NOTICE', () => { + expect(phaseToPayload('end', { env: 'dev' }).eventType).toBe('DeployFinished'); + expect(phaseToPayload('end', { env: 'dev' }).severity).toBe('NOTICE'); + }); + it('maps fail → DeployFailed / ERROR with detail in body', () => { + const p = phaseToPayload('fail', { env: 'qa', detail: 'smoke gate failed' }); + expect(p.eventType).toBe('DeployFailed'); + expect(p.severity).toBe('ERROR'); + expect(p.body).toContain('smoke gate failed'); + }); +}); + +describe('POST /ops/deploy-event', () => { + it('202 + raises alert for a valid start', async () => { + raiseMock.mockClear(); + const res = await post({ phase: 'start', env: 'prod', version: '9.9.9' }); + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 10)); // let the void raise() settle + expect(raiseMock).toHaveBeenCalledOnce(); + expect(raiseMock.mock.calls[0][0].eventType).toBe('DeployStarted'); + }); + it('400 on missing/invalid phase, no raise', async () => { + raiseMock.mockClear(); + const res = await post({ env: 'prod' }); + expect(res.status).toBe(400); + expect(raiseMock).not.toHaveBeenCalled(); + }); + it('still 202 when raise rejects (fail-open)', async () => { + raiseMock.mockClear(); + raiseMock.mockImplementationOnce(() => Promise.reject(new Error('ANS down'))); + const res = await post({ phase: 'end', env: 'dev' }); + expect(res.status).toBe(202); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run srv/routes/__tests__/deploy-events.test.js` +Expected: FAIL — `../deploy-events.js` does not exist. + +- [ ] **Step 3: Write the implementation** + +```js +// srv/routes/deploy-events.js +// +// POST /ops/deploy-event — operational endpoint pinged by scripts/deploy-mta.cjs +// at each deploy lifecycle boundary (start/end/fail). Bearer-guarded upstream by +// contentAuthMiddleware (CONTENT_API_KEY). Fail-open: always 202 on a well-formed +// request; alerting.raise is fire-and-forget and never blocks the response. +// Spec: docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md +import cds from '@sap/cds'; +import * as alerting from '../lib/alerting.js'; + +const LOG = cds.log('deploy-events'); + +const PHASE_MAP = { + start: { eventType: 'DeployStarted', severity: 'NOTICE', verb: 'started' }, + end: { eventType: 'DeployFinished', severity: 'NOTICE', verb: 'finished' }, + fail: { eventType: 'DeployFailed', severity: 'ERROR', verb: 'FAILED' }, +}; + +export function phaseToPayload(phase, { env, version, detail } = {}) { + const m = PHASE_MAP[phase]; + if (!m) return null; + const envLabel = env || 'unknown'; + const verSuffix = version ? ` ${version}` : ''; + return { + eventType: m.eventType, + severity: m.severity, + category: 'ALERT', + subject: `Deploy ${m.verb} — ${envLabel}${verSuffix}`, + body: detail || `Deploy ${m.verb} for ${envLabel}${verSuffix}.`, + resource: { resourceName: `deploy-${envLabel}`, resourceType: 'deployment' }, + }; +} + +async function handler(req, res) { + const { phase, env, version, detail } = req.body || {}; + const payload = phaseToPayload(phase, { env, version, detail }); + if (!payload) { + return res.status(400).json({ error: 'invalid or missing "phase" (start|end|fail)' }); + } + // Fire-and-forget; alerting.raise is itself fail-open. Never block the deploy. + void alerting.raise(payload); + LOG.info(`deploy-event ${phase} env=${env ?? '?'} version=${version ?? '?'}`); + return res.status(202).json({ ok: true }); +} + +export function register(app, { authMw } = {}) { + const express = app.request?.app?.constructor ?? null; // noop; body parser applied by caller + if (authMw) { + app.post('/ops/deploy-event', authMw, handler); + } else { + app.post('/ops/deploy-event', handler); + } +} +``` + +Note: the test mounts `register(app)` without a JSON body parser, so add one inside the module to stay self-contained for tests. Replace the `register` body with: + +```js +import express from 'express'; +// ... +export function register(app, { authMw } = {}) { + const parse = express.json({ limit: '16kb' }); + const chain = authMw ? [parse, authMw, handler] : [parse, handler]; + app.post('/ops/deploy-event', ...chain); +} +``` + +(Remove the stray `const express = ...` noop line — the real `import express from 'express'` at the top is what's used.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run srv/routes/__tests__/deploy-events.test.js` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add srv/routes/deploy-events.js srv/routes/__tests__/deploy-events.test.js +git commit -m "feat(alerts): POST /ops/deploy-event route mapping phase to ANS alert" +``` + +--- + +### Task 3: Wire the route into `srv/server.js` with auth + +**Files:** +- Modify: `srv/server.js` (import near line 37; registration near line 466, beside the other bearer-guarded `/content/*` and `/build/*` routes) + +**Interfaces:** +- Consumes: `register(app, { authMw })` from Task 2; `contentAuthMiddleware` already imported at `srv/server.js:28`. +- Produces: live `POST /ops/deploy-event` guarded by `contentAuthMiddleware` on the deployed srv. + +- [ ] **Step 1: Add the import** + +After line 37 (`import * as alertsPublic from './routes/alerts-public.js';`) add: + +```js +import * as deployEvents from './routes/deploy-events.js'; +``` + +- [ ] **Step 2: Register the route** + +Immediately after the `/content/pipeline-log` registration (line 466), add: + +```js + // Deploy lifecycle alerts (#deploy-alerts): scripts/deploy-mta.cjs pings this + // at start/end/fail of a deploy → ANS. Same bearer auth (CONTENT_API_KEY) as + // the other ops endpoints. Body parser is applied inside register(). + deployEvents.register(app, { authMw: contentAuthMiddleware }); +``` + +- [ ] **Step 3: Verify server boots and the route is guarded** + +Run: `npx vitest run srv/routes/__tests__/deploy-events.test.js` (still green — unaffected). +Then a boot smoke check: + +Run: `node -e "import('./srv/server.js').then(()=>console.log('import-ok')).catch(e=>{console.error(e);process.exit(1)})"` +Expected: no import/parse error (prints `import-ok` or hangs on server start — Ctrl-C is fine; the point is no `SyntaxError`/`Cannot find module`). If it hangs, that's the CAP server starting; treat clean startup logs as pass. + +- [ ] **Step 4: Run the broader unit suite for regressions** + +Run: `npm test -- srv/routes srv/lib/__tests__/deploy-alerts-config.test.js` +Expected: PASS, no new failures. + +- [ ] **Step 5: Commit** + +```bash +git add srv/server.js +git commit -m "feat(alerts): mount /ops/deploy-event with CONTENT_API_KEY auth" +``` + +--- + +### Task 4: `notifyDeploy` helper + fire points in `scripts/deploy-mta.cjs` + +**Files:** +- Modify: `scripts/deploy-mta.cjs` +- Test: `scripts/__tests__/notify-deploy.test.js` (create; if `scripts/__tests__/` doesn't exist, create it) + +**Interfaces:** +- Consumes: per-env `cfg.srvUrl` from the `ENVS` table; `process.env.CONTENT_API_KEY`. +- Produces: `notifyDeploy(phase, cfg, extra, deps)` — best-effort POST to `${cfg.srvUrl}/ops/deploy-event`. Returns a Promise that ALWAYS resolves (never rejects). `deps` injects `{ fetchImpl, log, apiKey }` for tests. Exported via `module.exports` alongside a guard that still runs `main()` when invoked as the entry script. + +- [ ] **Step 1: Write the failing test** + +```js +// scripts/__tests__/notify-deploy.test.js +const { describe, it, expect, vi } = require('vitest'); +const { notifyDeploy } = require('../deploy-mta.cjs'); + +const CFG = { srvUrl: 'https://srv.example.com' }; + +describe('notifyDeploy (best-effort)', () => { + it('POSTs the phase payload with bearer auth', async () => { + const calls = []; + const fetchImpl = (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true, status: 202 }); }; + await notifyDeploy('start', CFG, { env: 'prod', version: '1.0.0' }, { fetchImpl, apiKey: 'k', log: () => {} }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://srv.example.com/ops/deploy-event'); + expect(calls[0].opts.headers.Authorization).toBe('Bearer k'); + const body = JSON.parse(calls[0].opts.body); + expect(body).toMatchObject({ phase: 'start', env: 'prod', version: '1.0.0' }); + }); + it('never rejects on network error', async () => { + const fetchImpl = () => Promise.reject(new Error('ECONNREFUSED')); + await expect( + notifyDeploy('end', CFG, { env: 'dev' }, { fetchImpl, apiKey: 'k', log: () => {} }) + ).resolves.toBeUndefined(); + }); + it('no-ops (no fetch) when apiKey is absent', async () => { + const fetchImpl = vi.fn(); + await notifyDeploy('start', CFG, { env: 'dev' }, { fetchImpl, apiKey: '', log: () => {} }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/notify-deploy.test.js` +Expected: FAIL — `notifyDeploy` is not exported. + +- [ ] **Step 3: Add the helper** + +Near the other helpers in `scripts/deploy-mta.cjs` (after `shCapture`, ~line 120), add: + +```js +// --------------------------------------------------------------------------- +// Deploy lifecycle alert ping (best-effort). POSTs to the srv's +// /ops/deploy-event, which raises an ANS alert. NEVER throws, NEVER changes the +// deploy exit code — a down/misconfigured alerting path must not block a deploy. +// deps is a test seam: { fetchImpl, apiKey, log }. +// --------------------------------------------------------------------------- +async function notifyDeploy(phase, cfg, extra = {}, deps = {}) { + const fetchImpl = deps.fetchImpl || globalThis.fetch; + const apiKey = deps.apiKey !== undefined ? deps.apiKey : process.env.CONTENT_API_KEY; + const logFn = deps.log || warn; + if (!apiKey) { + logFn(`deploy-event ${phase}: CONTENT_API_KEY not set — skipping alert ping`); + return; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetchImpl(`${cfg.srvUrl}/ops/deploy-event`, { + method: 'POST', + headers: { 'content-type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ phase, ...extra }), + signal: controller.signal, + }); + if (!res.ok) logFn(`deploy-event ${phase}: srv returned ${res.status} (ignored)`); + } catch (e) { + logFn(`deploy-event ${phase}: ping failed (ignored) — ${e.message ?? e}`); + } finally { + clearTimeout(timer); + } +} +``` + +- [ ] **Step 4: Export for tests without breaking entry-script behavior** + +Replace the trailing `main();` (last line) with: + +```js +if (require.main === module) { + main(); +} + +module.exports = { notifyDeploy }; +``` + +- [ ] **Step 5: Wire the three fire points** + +The deploy version string for `extra` — reuse the MTA version already read in Step 1.5. Capture it into a script-scope variable when writing the version file, defaulting to `undefined`. In `writeVersionFile()` the value is `version`; hoist a `let deployVersion;` near the top of `main()` and set `deployVersion = v.version;` in the Step 1.5 else-branch (and `deployVersion = readMtaVersion();` in the `--skip-build` branch so it's populated there too). + +**(a) start** — in Step 4, in the real-deploy `else` branch, immediately BEFORE `const code = sh('cf', ['deploy', ...]);`: + +```js + await notifyDeploy('start', cfg, { env: envName, version: deployVersion }); +``` + +**(b) fail (cf deploy)** — in the same branch, inside `if (code !== 0) { ... }`, before `die(...)` (and before/after `abortFailedBlueGreen()` is fine — put it first): + +```js + await notifyDeploy('fail', cfg, { env: envName, version: deployVersion, detail: 'cf deploy failed' }); +``` + +**(c) blue-green paused note** — in the `if (bg) { ok('blue-green green apps up ...'); ... }` block, add a warn so the operator knows no auto-end fires: + +```js + warn('No automatic "deploy finished" alert will fire for blue-green (paused before swap).'); +``` + +**(d) end** — in Step 5, in the success branch, right after `ok('smoke tests passed — deploy verified');`: + +```js + await notifyDeploy('end', cfg, { env: envName, version: deployVersion }); +``` + +**(e) fail (smoke gate)** — in Step 5's `if (code !== 0) { ... process.exit(2); }`, before `process.exit(2)`: + +```js + await notifyDeploy('fail', cfg, { env: envName, version: deployVersion, detail: 'smoke gate failed' }); +``` + +Because `main()` is not `async`, wrap these `await notifyDeploy(...)` calls to run synchronously-enough: change `function main()` to `async function main()` and `main();` (inside the `require.main` guard) to `main();` unchanged (Node runs the async fn; unhandled rejection impossible since notifyDeploy never rejects). Verify no other `await` was needed before — the function currently has none, so making it async is safe. + +- [ ] **Step 6: Run the helper test** + +Run: `npx vitest run scripts/__tests__/notify-deploy.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 7: Dry-run the deploy script to confirm no behavior change** + +Run: `node scripts/deploy-mta.cjs --env dev --dry-run` +Expected: exits 0, prints the plan; NO deploy-event ping fired (dry-run skips Step 4/5 real branches). Confirm no stack trace and the `deployVersion` line doesn't throw. + +- [ ] **Step 8: Commit** + +```bash +git add scripts/deploy-mta.cjs scripts/__tests__/notify-deploy.test.js +git commit -m "feat(alerts): ping /ops/deploy-event at deploy start/end/fail" +``` + +--- + +### Task 5: MTA version bump + runbook note + +**Files:** +- Modify: `.deploy/mta.yaml` (`version:` field only) +- Modify: `docs/developers/operations/mta-deployment.md` (add a short section) + +**Interfaces:** +- Consumes: nothing. +- Produces: deployable artifact version + operator documentation for the new channel action provisioning and the blue-green caveat. + +- [ ] **Step 1: Bump the MTA version (minor)** + +In `.deploy/mta.yaml`, increment the top-level `version:` by a **minor** (e.g. `X.Y.Z` → `X.(Y+1).0`). Read the current value first: + +Run: `grep -m1 '^version:' .deploy/mta.yaml` +Then edit that single line. + +- [ ] **Step 2: Add the runbook note** + +Append to `docs/developers/operations/mta-deployment.md`: + +```markdown +## Deploy lifecycle alerts (start / end / fail) + +`npm run deploy -- --env ` pings the deployed srv's `POST /ops/deploy-event` +(bearer `CONTENT_API_KEY`) at three points: **start** (before `cf deploy`), +**end** (after the smoke gate passes), and **fail** (on a `cf deploy` or +smoke-gate failure). The srv raises an SAP Alert Notification event: + +- `DeployStarted` / `DeployFinished` → severity `NOTICE` → `email:devrel-deploys`. +- `DeployFailed` → severity `ERROR` → `email:devrel-deploys` **and** `email:devrel-oncall`. + +**Prerequisites for delivery:** +1. `ChatSettings.alertsEnabled` must be ON in the target env (admin UI `/admin-ui`). + Default is OFF. (Note: a deploy that flips this flag can suppress its own + end/fail ping — accepted edge case.) +2. The `devrel-deploys` channel's email **action** must be provisioned in ANS + (cockpit / `gen/alerts/provision.sh`) with the real distribution-list address, + exactly like `devrel-oncall`. +3. `CONTENT_API_KEY` must be present in the operator/CI environment running the + deploy (it already is, for content publish). + +**Blue-green caveat:** a `--strategy blue-green` deploy pauses before the traffic +swap and exits, so it emits **start** and (on failure) **fail**, but NOT an +automatic **finished** — the swap happens later via `cf deploy -i -a resume`. +``` + +- [ ] **Step 3: Verify docs/yaml parse** + +Run: `npx yaml < .deploy/mta.yaml >/dev/null && echo yaml-ok` (or `python -c "import yaml,sys;yaml.safe_load(open('.deploy/mta.yaml'))" && echo yaml-ok`) +Expected: `yaml-ok`. + +- [ ] **Step 4: Commit** + +```bash +git add .deploy/mta.yaml docs/developers/operations/mta-deployment.md +git commit -m "chore(alerts): bump MTA version + document deploy lifecycle alerts" +``` + +--- + +### Task 6: Full-suite regression + `srv-qa` cp-list audit + +**Files:** +- Verify only: `.deploy/mta.yaml` `srv-qa` `cp:` list + +- [ ] **Step 1: Confirm no new `srv/lib/` transitive dep** + +`srv/routes/deploy-events.js` imports only `@sap/cds`, `express`, and `srv/lib/alerting.js` (already shipped). Per the project rule, re-walk `./` imports: + +Run: `grep -nE "from '\\.|require\\('\\." srv/routes/deploy-events.js` +Expected: only `../lib/alerting.js`. Confirm `srv/lib/alerting.js` and its deps (`./runtime-config/alert-settings.js`) are already in the `srv-qa` `cp:` list (they were added in prior alerts work — commit `ba9c3fed`). No change expected; if `deploy-events.js` needs to be reachable by srv-qa, note that srv-qa does NOT mount this route (deploy pings target the main srv only), so no cp addition is required. + +- [ ] **Step 2: Run the full unit suite** + +Run: `npm test` +Expected: PASS — no regressions. New tests from Tasks 1, 2, 4 are green. + +- [ ] **Step 3: Lint the deploy script** + +Run: `npx eslint scripts/deploy-mta.cjs srv/routes/deploy-events.js` (if eslint is configured; otherwise `node --check scripts/deploy-mta.cjs && echo syntax-ok`) +Expected: clean / `syntax-ok`. + +- [ ] **Step 4: Final commit (if any lint fixups)** + +```bash +git add -A && git commit -m "chore(alerts): lint + srv-qa cp-list audit for deploy events" || echo "nothing to commit" +``` + +--- + +## Post-implementation (operator-owned, NOT in this plan) + +- Provision the `devrel-deploys` email action in ANS with the real address. +- Flip `ChatSettings.alertsEnabled` ON in the target env(s). +- Deploy the srv (this ships the new endpoint + config), then run a real + `npm run deploy -- --env dev` and confirm the start/end emails arrive. +- Open a PR (`gh pr create`) — do NOT direct-merge to main. + +## Self-Review Notes + +- **Spec §4 (contract):** Task 2 (phase map, 202/400, fail-open). ✓ +- **Spec §5 (routing):** Task 1 (channel/route/eventTypes + `resolveChannels` asserts). ✓ +- **Spec §6 (script integration):** Task 4 (helper + 3 fire points, all envs, dry-run guard). ✓ +- **Spec §7 (blue-green):** Task 4 step 5(c) warn + Task 5 runbook caveat. ✓ +- **Spec §8 (error handling):** Task 2 fail-open test + Task 4 never-rejects test. ✓ +- **Spec §9 (testing):** Tasks 1/2/4 unit tests, no supertest. ✓ +- **Spec §10 (files):** all listed files have a task. ✓ (server.js=Task 3, mta.yaml/runbook=Task 5) +- **Spec §11 (srv-qa cp list):** Task 6 explicit audit. ✓ +- **Type consistency:** `notifyDeploy(phase, cfg, extra, deps)`, `phaseToPayload(phase, {env,version,detail})`, `register(app,{authMw})` used identically across tasks. ✓ From 7c09be6e20812b252b4fc5c1f1ea7b041ae11e12 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:04:17 -0400 Subject: [PATCH 3/8] feat(alerts): add deploy eventTypes + dedicated deploys channel/route --- package.json | 665 +++++++++--------- .../__tests__/deploy-alerts-config.test.js | 28 + 2 files changed, 372 insertions(+), 321 deletions(-) create mode 100644 srv/lib/__tests__/deploy-alerts-config.test.js diff --git a/package.json b/package.json index 40dc29e0b..7d5170907 100644 --- a/package.json +++ b/package.json @@ -1,321 +1,344 @@ -{ - "name": "tutorials-ims", - "version": "1.0.0", - "private": true, - "type": "module", - "engines": { - "node": ">=22.12" - }, - "scripts": { - "start": "cds-serve", - "watch": "cds watch", - "watch:hybrid": "cds watch --profile hybrid", - "dev:hybrid": "npm run watch:hybrid & npm run start:approuter", - "start:approuter": "cd approuter && NODE_ENV=development npm start", - "bind:setup": "node scripts/setup-hybrid-env.js", - "seed:thirdparty": "cds bind --exec -- node scripts/seed-thirdparty.js", - "setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && sh scripts/install-git-hooks.sh", - "build:cds": "cds build --production", - "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", - "fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts", - "fetch-verb-definitions": "tsx scripts/fetch-verb-definitions.ts", - "fetch-shelf-definitions": "tsx scripts/fetch-shelf-definitions.ts", - "fetch-featured-topics": "tsx scripts/fetch-featured-topics.ts", - "fetch-topic-clusters": "tsx scripts/fetch-topic-clusters.ts", - "fetch-tutorials:hugo": "tsx scripts/fetch-tutorials.ts --target hugo", - "fetch-concepts": "tsx scripts/fetch-concepts.ts", - "fetch-advocates": "tsx scripts/fetch-advocates.ts", - "seed-ai-quizzes": "cross-env AI_AUTHOR_BUILD_CAP=10000 npm run fetch-tutorials", - "discover-repos": "tsx scripts/fetch-tutorials.ts --discover-only", - "generate-dark-theme": "tsx scripts/generate-dark-theme.ts", - "validate-tutorials": "tsx scripts/validate-tutorials.ts", - "lint:tutorial-markdown": "tsx scripts/lint-tutorial-markdown.ts", - "publish-content": "tsx scripts/publish-content.ts", - "preflight:ai-quiz-smoke": "tsx scripts/preflight-ai-quiz-smoke.ts", - "prebuild:parsers-bundle": "esbuild scripts/parsers/index.ts --bundle --platform=node --format=esm --outfile=srv-qa/lib/parsers.bundle.mjs --external:node:* --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", - "check:security-annotations": "node scripts/check-hugo-safe-html.cjs", - "check:api-docs-drift": "node scripts/check-api-docs-drift.cjs", - "prebuild": "npm run prebuild:parsers-bundle && npm run check:security-annotations", - "fetch-tutorials:qa": "tsx scripts/fetch-tutorials.ts --target hugo --channel qa", - "build:qa": "hugo --source hugo --config ../hugo.qa.toml --minify && tsx scripts/verify-qa-build.ts hugo/public-qa", - "publish-content:qa": "tsx scripts/publish-content.ts --channel qa", - "qa:full": "npm run fetch-tutorials:qa && npm run build:qa && npm run publish-content:qa", - "setup-dev-data": "cds bind --exec -- node scripts/setup-dev-data.cjs", - "seed-poc-puzzle": "cds bind --exec -- node srv/lib/seed-poc-puzzle.js", - "export:advocates": "cds bind --exec -- node scripts/export-advocates.cjs", - "import:advocates": "cds bind --exec -- node scripts/import-advocates.cjs", - "backfill-categories": "node scripts/backfill-categories.cjs", - "kg:reextract": "cross-env KG_EXTRACT_BUILD_CAP=10000 cds bind --exec -- node scripts/kg-reextract.cjs", - "seed-tag-labels": "tsx scripts/seed-tag-labels.ts", - "migrate:reference": "node scripts/migrate-reference-data.js", - "migrate:users": "node scripts/migrate-user-progress.js", - "migrate:hana": "node scripts/migrate-from-hana.js", - "migrate:authors": "cds bind --exec -- node scripts/backfill-tutorial-authors.cjs --commit", - "migrate:meta-authors": "cds bind --exec -- node scripts/backfill-tutorial-meta-author.cjs --commit", - "migrate:btp-roles": "node scripts/migrate-btp-roles.js", - "cutover:rehearsal": "node scripts/cutover-rehearsal.cjs", - "verify:rowcounts": "node scripts/verify-migration-rowcounts.cjs", - "compare": "node scripts/compare-systems.js", - "hana:rowcounts": "cds bind --exec -- node scripts/check-hana-rowcounts.cjs", - "hana:scrape-deployer-log": "node scripts/scrape-deployer-log.cjs", - "hana:orphan-views": "node scripts/check-orphan-views.cjs", - "build:css": "postcss hugo/assets/css/sap-fundamental.css --config hugo/ --no-map -o hugo/static/css/sap-fundamental.css", - "vendor:mediapipe": "node scripts/vendor-mediapipe.cjs", - "build:apps": "npm run vendor:mediapipe && npm --prefix hugo-apps run build", - "postbuild:apps": "tsx scripts/check-build-collisions.ts && tsx scripts/check-icon-imports.ts && tsx scripts/check-island-ui5-imports.ts && tsx scripts/check-xs-app-mta.ts && tsx scripts/check-public-endpoints.ts && tsx scripts/check-srv-qa-cp-list.ts && tsx scripts/check-srv-qa-route-drift.ts && tsx scripts/check-srv-qa-dep-parity.ts && tsx scripts/check-slug-lookups.ts && tsx scripts/check-ui5-controller-extensions.ts && tsx scripts/check-kg-meta-formatters-mirror.ts && tsx scripts/check-csrf-clients.ts && npm run check:graphql-breaking", - "build:explore-manifest": "tsx scripts/build-explore-manifest.ts", - "build:explore": "npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/explore run build && npm run build:explore-manifest", - "build:analytics-explorer": "npm --prefix app/analytics-explorer install && npm --prefix app/analytics-explorer run build", - "check-explore-bundle-manifest": "node scripts/check-explore-bundle-manifest.cjs", - "check-verb-shelves": "node scripts/check-verb-shelves.cjs", - "build:hugo": "npm run check-explore-bundle-manifest && npm run check-verb-shelves && hugo --source hugo --minify", - "build:highlight": "tsx scripts/highlight-cds.ts", - "build:display": "cd app/display-app && npm install && npm run build", - "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", - "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", - "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-concepts && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:css && npm run build:apps && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:highlight && npm run build:display && npm run build:sdl", - "build:deploy": "npm run check-deploy-cap-target && npm run build:all", - "deploy": "node scripts/deploy-mta.cjs", - "build:admin": "npm --prefix app/admin-shell run build", - "predev": "npm run copy-joule-vendor && node -e \"var r=require('fs').existsSync('srv/data/admin-docs-index.json')?{status:0}:require('child_process').spawnSync('npx',['tsx','scripts/build-admin-docs-index.ts'],{stdio:'inherit',shell:true});if(r.status!==0)process.exit(r.status||1)\"", - "prewatch": "node -e \"var r=require('fs').existsSync('srv/data/admin-docs-index.json')?{status:0}:require('child_process').spawnSync('npx',['tsx','scripts/build-admin-docs-index.ts'],{stdio:'inherit',shell:true});if(r.status!==0)process.exit(r.status||1)\"", - "prebuild:cds": "npx tsx scripts/build-admin-docs-index.ts", - "dev": "node scripts/ensure-explore-manifest.cjs && hugo server --source hugo", - "predocs:dev": "node scripts/copy-sap-fonts.cjs", - "docs:dev": "vitepress dev docs", - "predocs:build": "node scripts/copy-sap-fonts.cjs && node scripts/check-docs-sidebar.cjs", - "docs:build": "vitepress build docs", - "docs:preview": "vitepress preview docs", - "loadtest:baseline": "k6 run test/load/scenarios/02-public-baseline.js", - "loadtest:ramp": "k6 run test/load/scenarios/03-public-ramp.js", - "loadtest:smoke": "k6 run test/load/scenarios/01-smoke.js", - "loadtest:tutorials": "k6 run test/load/scenarios/04-tutorial-serve.js", - "loadtest:ws": "k6 run test/load/scenarios/05-websocket-handshake.js", - "test": "vitest run --project unit", - "pretest": "npm run prebuild:parsers-bundle", - "test:watch": "vitest --project unit", - "test:hybrid": "cds bind --exec -- npx vitest run --project hybrid", - "test:hybrid:watch": "cds bind --exec -- npx vitest --project hybrid", - "test:smoke": "vitest run --project smoke", - "test:llm-ux": "node test/mcp-ux/runner.js", - "test:smoke:personalization": "vitest run test/smoke/homepage-personalized.test.js", - "test:a11y": "vitest run --project a11y", - "test:e2e": "vitest run --project e2e", - "test:a11y:lighthouse": "lhci autorun --config=test/a11y/lighthouserc.json", - "test:a11y:summary": "node test/a11y/summary.js", - "test:all": "cds bind --exec -- npx vitest run", - "validate-api-docs-yaml": "node scripts/validate-api-docs-yaml.cjs", - "build:sdl": "tsx scripts/emit-graphql-sdl.ts", - "check:graphql-breaking": "tsx scripts/check-graphql-breaking.ts", - "install-notify-workflows": "tsx scripts/install-notify-workflows.ts" - }, - "devDependencies": { - "@axe-core/playwright": "^4.12.1", - "@cap-js/cds-test": "^1.0.1", - "@lhci/cli": "^0.15.1", - "@sap-theming/theming-base-content": "^11.36.4", - "@sap/cds-dk": "^10.0.3", - "@types/sanitize-html": "2.16.1", - "@ui5/webcomponents": "^2.23.2", - "@ui5/webcomponents-fiori": "^2.23.2", - "@ui5/webcomponents-icons": "^2.23.2", - "@vitejs/plugin-vue": "6.0.7", - "@vue/test-utils": "2.4.11", - "cross-env": "10.1.0", - "dompurify": "3.4.11", - "esbuild": "0.28.1", - "fundamental-styles": "^0.41.7", - "gray-matter": "^4.0.3", - "happy-dom": "20.10.6", - "playwright-core": "^1.61.1", - "postcss": "^8.5.16", - "postcss-cli": "^11.0.1", - "postcss-import": "^16.1.1", - "probe-image-size": "^7.3.0", - "sanitize-html": "2.17.5", - "sap.tutorials.admin.accomplishments": "file:app/admin/accomplishments", - "sap.tutorials.admin.accounts": "file:app/admin/accounts", - "sap.tutorials.admin.changelog": "file:app/admin/changelog", - "sap.tutorials.admin.events": "file:app/admin/events", - "sap.tutorials.admin.groups": "file:app/admin/groups", - "sap.tutorials.admin.joule": "file:app/admin/joule", - "sap.tutorials.admin.missions": "file:app/admin/missions", - "sap.tutorials.admin.operations": "file:app/admin/operations", - "sap.tutorials.admin.prizes": "file:app/admin/prizes", - "sap.tutorials.admin.tags": "file:app/admin/tags", - "sap.tutorials.admin.tutorials": "file:app/admin/tutorials", - "shiki": "^4.3.0", - "tsx": "^4.22.5", - "vitepress": "~1.6.4", - "vitest": "^4.1.9", - "yaml": "^2.9.0", - "yauzl": "3.4.0" - }, - "dependencies": { - "@sap-tutorials/cds-alert-notification": "^1.0.0", - "@cap-js-community/websocket": "^1.10.5", - "@cap-js/ai": "~1.0.1", - "@cap-js/audit-logging": "^1.2.2", - "@cap-js/change-tracking": "^2.0.1", - "@cap-js/data-inspector": "1.0.5", - "@cap-js/graphql": "0.14.0", - "@cap-js/hana": "^3.0.1", - "@cap-js/mcp": "1.1.1", - "@cap-js/ord": "^1.9.1", - "@cap-js/sqlite": "^3.0.2", - "@cap-js/telemetry": "^2.0.1", - "@grpc/grpc-js": "^1.14.4", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.220.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0", - "@sap-ai-sdk/foundation-models": "^2.12.0", - "@sap-ai-sdk/orchestration": "^2.12.0", - "@sap/cds": "^10.0.3", - "@sap/xsenv": "^6.2.1", - "@sap/xssec": "^4.13.1", - "ajv": "8.20.0", - "archiver": "8.0.0", - "cds-caching": "2.0.2", - "cds-swagger-ui-express": "^0.11.0", - "cheerio": "^1.2.0", - "cron-parser": "5.6.1", - "csv-parse": "7.0.1", - "csv-stringify": "6.8.1", - "ejs": "3.1.10", - "exceljs": "4.4.0", - "hdb": "^2.29.5", - "jose": "6.2.3", - "js-yaml": "5.2.1", - "markdown-it": "^14.3.0", - "mermaid": "^11.16.0", - "multer": "2.2.0", - "node-sql-parser": "^5.4.0", - "nodemailer": "9.0.3", - "passport": "^0.7.0", - "qrcode": "^1.5.4", - "sharp": "0.35.3", - "socket.io": "^4.8.3", - "undici": "8.6.0", - "vue-virtual-scroller": "3.0.4" - }, - "cds": { - "protocols": { - "graphql": { - "path": "/graphql/_plugin" - } - }, - "requires": { - "AICore": { - "resourceGroup": "default", - "[hybrid]": { - "kind": "AICore-btp" - }, - "[production]": { - "kind": "AICore-btp" - } - }, - "caching": { - "impl": "cds-caching", - "namespace": "kg", - "store": "memory", - "[hybrid]": { - "store": "cds", - "metrics": { - "enabled": true - } - }, - "[production]": { - "store": "cds", - "metrics": { - "enabled": true - } - } - }, - "db": { - "[hybrid]": { - "kind": "hana" - }, - "[production]": { - "kind": "hana" - } - }, - "audit-log": { - "[hybrid]": { - "kind": "audit-log-to-console" - } - }, - "ngds": { - "kind": "rest", - "[hybrid]": { - "kind": "rest", - "credentials": { - "destination": "ngds-destination", - "path": "/ngds/developers/ims" - } - }, - "[production]": { - "kind": "rest", - "credentials": { - "destination": "ngds-destination", - "path": "/ngds/developers/ims" - } - } - }, - "telemetry": { - "kind": "telemetry-to-console", - "tracing": { - "sampler": { - "kind": "ParentBasedSampler", - "root": "AlwaysOnSampler", - "ignoreIncomingPaths": [ - "/health", - "/health/db" - ] - } - }, - "[production]": { - "kind": "telemetry-to-cloud-logging" - } - }, - "alerts": { - "impl": "@sap-tutorials/cds-alert-notification", - "kind": "alert-notification-console", - "[test]": { - "kind": "alert-notification-memory" - }, - "[hybrid]": { - "kind": "alert-notification" - }, - "[production]": { - "kind": "alert-notification" - }, - "channels": ["email:devrel-oncall"], - "routes": [{ "minSeverity": "ERROR", "channels": ["email:devrel-oncall"] }], - "eventTypes": ["PublishRejected", "ScheduledJobFailed", "RebuildDispatchFailed"], - "dedupWindowMs": 300000 - } - }, - "hana": { - "fuzzy": false - }, - "websocket": { - "kind": "socket.io" - }, - "ord": { - "namespace": "sap.tutorials", - "description": "SAP Developer Tutorial Platform — progress tracking, mission management, and real-time event dashboards for developers.sap.com", - "policyLevels": [ - "sap:core:v1" - ], - "defaultVisibility": "public" - }, - "[development]": { - "swagger": { - "basePath": "/$api-docs", - "diagram": true - } - }, - "mcp": { - "per_action_tool": true - } - } -} +{ + "name": "tutorials-ims", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "start": "cds-serve", + "watch": "cds watch", + "watch:hybrid": "cds watch --profile hybrid", + "dev:hybrid": "npm run watch:hybrid & npm run start:approuter", + "start:approuter": "cd approuter && NODE_ENV=development npm start", + "bind:setup": "node scripts/setup-hybrid-env.js", + "seed:thirdparty": "cds bind --exec -- node scripts/seed-thirdparty.js", + "setup": "npm --prefix hugo-apps install --no-audit --no-fund && npm --prefix app/explore install --no-audit --no-fund && sh scripts/install-git-hooks.sh", + "build:cds": "cds build --production", + "fetch-tutorials": "tsx scripts/fetch-tutorials.ts --target hugo", + "fetch-homepage-shelves": "tsx scripts/fetch-homepage-shelves.ts", + "fetch-verb-definitions": "tsx scripts/fetch-verb-definitions.ts", + "fetch-shelf-definitions": "tsx scripts/fetch-shelf-definitions.ts", + "fetch-featured-topics": "tsx scripts/fetch-featured-topics.ts", + "fetch-topic-clusters": "tsx scripts/fetch-topic-clusters.ts", + "fetch-tutorials:hugo": "tsx scripts/fetch-tutorials.ts --target hugo", + "fetch-concepts": "tsx scripts/fetch-concepts.ts", + "fetch-advocates": "tsx scripts/fetch-advocates.ts", + "seed-ai-quizzes": "cross-env AI_AUTHOR_BUILD_CAP=10000 npm run fetch-tutorials", + "discover-repos": "tsx scripts/fetch-tutorials.ts --discover-only", + "generate-dark-theme": "tsx scripts/generate-dark-theme.ts", + "validate-tutorials": "tsx scripts/validate-tutorials.ts", + "lint:tutorial-markdown": "tsx scripts/lint-tutorial-markdown.ts", + "publish-content": "tsx scripts/publish-content.ts", + "preflight:ai-quiz-smoke": "tsx scripts/preflight-ai-quiz-smoke.ts", + "prebuild:parsers-bundle": "esbuild scripts/parsers/index.ts --bundle --platform=node --format=esm --outfile=srv-qa/lib/parsers.bundle.mjs --external:node:* --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "check:security-annotations": "node scripts/check-hugo-safe-html.cjs", + "check:api-docs-drift": "node scripts/check-api-docs-drift.cjs", + "prebuild": "npm run prebuild:parsers-bundle && npm run check:security-annotations", + "fetch-tutorials:qa": "tsx scripts/fetch-tutorials.ts --target hugo --channel qa", + "build:qa": "hugo --source hugo --config ../hugo.qa.toml --minify && tsx scripts/verify-qa-build.ts hugo/public-qa", + "publish-content:qa": "tsx scripts/publish-content.ts --channel qa", + "qa:full": "npm run fetch-tutorials:qa && npm run build:qa && npm run publish-content:qa", + "setup-dev-data": "cds bind --exec -- node scripts/setup-dev-data.cjs", + "seed-poc-puzzle": "cds bind --exec -- node srv/lib/seed-poc-puzzle.js", + "export:advocates": "cds bind --exec -- node scripts/export-advocates.cjs", + "import:advocates": "cds bind --exec -- node scripts/import-advocates.cjs", + "backfill-categories": "node scripts/backfill-categories.cjs", + "kg:reextract": "cross-env KG_EXTRACT_BUILD_CAP=10000 cds bind --exec -- node scripts/kg-reextract.cjs", + "seed-tag-labels": "tsx scripts/seed-tag-labels.ts", + "migrate:reference": "node scripts/migrate-reference-data.js", + "migrate:users": "node scripts/migrate-user-progress.js", + "migrate:hana": "node scripts/migrate-from-hana.js", + "migrate:authors": "cds bind --exec -- node scripts/backfill-tutorial-authors.cjs --commit", + "migrate:meta-authors": "cds bind --exec -- node scripts/backfill-tutorial-meta-author.cjs --commit", + "migrate:btp-roles": "node scripts/migrate-btp-roles.js", + "cutover:rehearsal": "node scripts/cutover-rehearsal.cjs", + "verify:rowcounts": "node scripts/verify-migration-rowcounts.cjs", + "compare": "node scripts/compare-systems.js", + "hana:rowcounts": "cds bind --exec -- node scripts/check-hana-rowcounts.cjs", + "hana:scrape-deployer-log": "node scripts/scrape-deployer-log.cjs", + "hana:orphan-views": "node scripts/check-orphan-views.cjs", + "build:css": "postcss hugo/assets/css/sap-fundamental.css --config hugo/ --no-map -o hugo/static/css/sap-fundamental.css", + "vendor:mediapipe": "node scripts/vendor-mediapipe.cjs", + "build:apps": "npm run vendor:mediapipe && npm --prefix hugo-apps run build", + "postbuild:apps": "tsx scripts/check-build-collisions.ts && tsx scripts/check-icon-imports.ts && tsx scripts/check-island-ui5-imports.ts && tsx scripts/check-xs-app-mta.ts && tsx scripts/check-public-endpoints.ts && tsx scripts/check-srv-qa-cp-list.ts && tsx scripts/check-srv-qa-route-drift.ts && tsx scripts/check-srv-qa-dep-parity.ts && tsx scripts/check-slug-lookups.ts && tsx scripts/check-ui5-controller-extensions.ts && tsx scripts/check-kg-meta-formatters-mirror.ts && tsx scripts/check-csrf-clients.ts && npm run check:graphql-breaking", + "build:explore-manifest": "tsx scripts/build-explore-manifest.ts", + "build:explore": "npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/explore run build && npm run build:explore-manifest", + "build:analytics-explorer": "npm --prefix app/analytics-explorer install && npm --prefix app/analytics-explorer run build", + "check-explore-bundle-manifest": "node scripts/check-explore-bundle-manifest.cjs", + "check-verb-shelves": "node scripts/check-verb-shelves.cjs", + "build:hugo": "npm run check-explore-bundle-manifest && npm run check-verb-shelves && hugo --source hugo --minify", + "build:highlight": "tsx scripts/highlight-cds.ts", + "build:display": "cd app/display-app && npm install && npm run build", + "copy-joule-vendor": "node scripts/copy-joule-vendor.mjs", + "check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs", + "build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-concepts && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run build:css && npm run build:apps && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:highlight && npm run build:display && npm run build:sdl", + "build:deploy": "npm run check-deploy-cap-target && npm run build:all", + "deploy": "node scripts/deploy-mta.cjs", + "build:admin": "npm --prefix app/admin-shell run build", + "predev": "npm run copy-joule-vendor && node -e \"var r=require('fs').existsSync('srv/data/admin-docs-index.json')?{status:0}:require('child_process').spawnSync('npx',['tsx','scripts/build-admin-docs-index.ts'],{stdio:'inherit',shell:true});if(r.status!==0)process.exit(r.status||1)\"", + "prewatch": "node -e \"var r=require('fs').existsSync('srv/data/admin-docs-index.json')?{status:0}:require('child_process').spawnSync('npx',['tsx','scripts/build-admin-docs-index.ts'],{stdio:'inherit',shell:true});if(r.status!==0)process.exit(r.status||1)\"", + "prebuild:cds": "npx tsx scripts/build-admin-docs-index.ts", + "dev": "node scripts/ensure-explore-manifest.cjs && hugo server --source hugo", + "predocs:dev": "node scripts/copy-sap-fonts.cjs", + "docs:dev": "vitepress dev docs", + "predocs:build": "node scripts/copy-sap-fonts.cjs && node scripts/check-docs-sidebar.cjs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs", + "loadtest:baseline": "k6 run test/load/scenarios/02-public-baseline.js", + "loadtest:ramp": "k6 run test/load/scenarios/03-public-ramp.js", + "loadtest:smoke": "k6 run test/load/scenarios/01-smoke.js", + "loadtest:tutorials": "k6 run test/load/scenarios/04-tutorial-serve.js", + "loadtest:ws": "k6 run test/load/scenarios/05-websocket-handshake.js", + "test": "vitest run --project unit", + "pretest": "npm run prebuild:parsers-bundle", + "test:watch": "vitest --project unit", + "test:hybrid": "cds bind --exec -- npx vitest run --project hybrid", + "test:hybrid:watch": "cds bind --exec -- npx vitest --project hybrid", + "test:smoke": "vitest run --project smoke", + "test:llm-ux": "node test/mcp-ux/runner.js", + "test:smoke:personalization": "vitest run test/smoke/homepage-personalized.test.js", + "test:a11y": "vitest run --project a11y", + "test:e2e": "vitest run --project e2e", + "test:a11y:lighthouse": "lhci autorun --config=test/a11y/lighthouserc.json", + "test:a11y:summary": "node test/a11y/summary.js", + "test:all": "cds bind --exec -- npx vitest run", + "validate-api-docs-yaml": "node scripts/validate-api-docs-yaml.cjs", + "build:sdl": "tsx scripts/emit-graphql-sdl.ts", + "check:graphql-breaking": "tsx scripts/check-graphql-breaking.ts", + "install-notify-workflows": "tsx scripts/install-notify-workflows.ts" + }, + "devDependencies": { + "@axe-core/playwright": "^4.12.1", + "@cap-js/cds-test": "^1.0.1", + "@lhci/cli": "^0.15.1", + "@sap-theming/theming-base-content": "^11.36.4", + "@sap/cds-dk": "^10.0.3", + "@types/sanitize-html": "2.16.1", + "@ui5/webcomponents": "^2.23.2", + "@ui5/webcomponents-fiori": "^2.23.2", + "@ui5/webcomponents-icons": "^2.23.2", + "@vitejs/plugin-vue": "6.0.7", + "@vue/test-utils": "2.4.11", + "cross-env": "10.1.0", + "dompurify": "3.4.11", + "esbuild": "0.28.1", + "fundamental-styles": "^0.41.7", + "gray-matter": "^4.0.3", + "happy-dom": "20.10.6", + "playwright-core": "^1.61.1", + "postcss": "^8.5.16", + "postcss-cli": "^11.0.1", + "postcss-import": "^16.1.1", + "probe-image-size": "^7.3.0", + "sanitize-html": "2.17.5", + "sap.tutorials.admin.accomplishments": "file:app/admin/accomplishments", + "sap.tutorials.admin.accounts": "file:app/admin/accounts", + "sap.tutorials.admin.changelog": "file:app/admin/changelog", + "sap.tutorials.admin.events": "file:app/admin/events", + "sap.tutorials.admin.groups": "file:app/admin/groups", + "sap.tutorials.admin.joule": "file:app/admin/joule", + "sap.tutorials.admin.missions": "file:app/admin/missions", + "sap.tutorials.admin.operations": "file:app/admin/operations", + "sap.tutorials.admin.prizes": "file:app/admin/prizes", + "sap.tutorials.admin.tags": "file:app/admin/tags", + "sap.tutorials.admin.tutorials": "file:app/admin/tutorials", + "shiki": "^4.3.0", + "tsx": "^4.22.5", + "vitepress": "~1.6.4", + "vitest": "^4.1.9", + "yaml": "^2.9.0", + "yauzl": "3.4.0" + }, + "dependencies": { + "@sap-tutorials/cds-alert-notification": "^1.0.0", + "@cap-js-community/websocket": "^1.10.5", + "@cap-js/ai": "~1.0.1", + "@cap-js/audit-logging": "^1.2.2", + "@cap-js/change-tracking": "^2.0.1", + "@cap-js/data-inspector": "1.0.5", + "@cap-js/graphql": "0.14.0", + "@cap-js/hana": "^3.0.1", + "@cap-js/mcp": "1.1.1", + "@cap-js/ord": "^1.9.1", + "@cap-js/sqlite": "^3.0.2", + "@cap-js/telemetry": "^2.0.1", + "@grpc/grpc-js": "^1.14.4", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0", + "@sap-ai-sdk/foundation-models": "^2.12.0", + "@sap-ai-sdk/orchestration": "^2.12.0", + "@sap/cds": "^10.0.3", + "@sap/xsenv": "^6.2.1", + "@sap/xssec": "^4.13.1", + "ajv": "8.20.0", + "archiver": "8.0.0", + "cds-caching": "2.0.2", + "cds-swagger-ui-express": "^0.11.0", + "cheerio": "^1.2.0", + "cron-parser": "5.6.1", + "csv-parse": "7.0.1", + "csv-stringify": "6.8.1", + "ejs": "3.1.10", + "exceljs": "4.4.0", + "hdb": "^2.29.5", + "jose": "6.2.3", + "js-yaml": "5.2.1", + "markdown-it": "^14.3.0", + "mermaid": "^11.16.0", + "multer": "2.2.0", + "node-sql-parser": "^5.4.0", + "nodemailer": "9.0.3", + "passport": "^0.7.0", + "qrcode": "^1.5.4", + "sharp": "0.35.3", + "socket.io": "^4.8.3", + "undici": "8.6.0", + "vue-virtual-scroller": "3.0.4" + }, + "cds": { + "protocols": { + "graphql": { + "path": "/graphql/_plugin" + } + }, + "requires": { + "AICore": { + "resourceGroup": "default", + "[hybrid]": { + "kind": "AICore-btp" + }, + "[production]": { + "kind": "AICore-btp" + } + }, + "caching": { + "impl": "cds-caching", + "namespace": "kg", + "store": "memory", + "[hybrid]": { + "store": "cds", + "metrics": { + "enabled": true + } + }, + "[production]": { + "store": "cds", + "metrics": { + "enabled": true + } + } + }, + "db": { + "[hybrid]": { + "kind": "hana" + }, + "[production]": { + "kind": "hana" + } + }, + "audit-log": { + "[hybrid]": { + "kind": "audit-log-to-console" + } + }, + "ngds": { + "kind": "rest", + "[hybrid]": { + "kind": "rest", + "credentials": { + "destination": "ngds-destination", + "path": "/ngds/developers/ims" + } + }, + "[production]": { + "kind": "rest", + "credentials": { + "destination": "ngds-destination", + "path": "/ngds/developers/ims" + } + } + }, + "telemetry": { + "kind": "telemetry-to-console", + "tracing": { + "sampler": { + "kind": "ParentBasedSampler", + "root": "AlwaysOnSampler", + "ignoreIncomingPaths": [ + "/health", + "/health/db" + ] + } + }, + "[production]": { + "kind": "telemetry-to-cloud-logging" + } + }, + "alerts": { + "impl": "@sap-tutorials/cds-alert-notification", + "kind": "alert-notification-console", + "[test]": { + "kind": "alert-notification-memory" + }, + "[hybrid]": { + "kind": "alert-notification" + }, + "[production]": { + "kind": "alert-notification" + }, + "channels": [ + "email:devrel-oncall", + "email:devrel-deploys" + ], + "routes": [ + { + "minSeverity": "ERROR", + "channels": [ + "email:devrel-oncall" + ] + }, + { + "minSeverity": "NOTICE", + "channels": [ + "email:devrel-deploys" + ] + } + ], + "eventTypes": [ + "PublishRejected", + "ScheduledJobFailed", + "RebuildDispatchFailed", + "DeployStarted", + "DeployFinished", + "DeployFailed" + ], + "dedupWindowMs": 300000 + } + }, + "hana": { + "fuzzy": false + }, + "websocket": { + "kind": "socket.io" + }, + "ord": { + "namespace": "sap.tutorials", + "description": "SAP Developer Tutorial Platform — progress tracking, mission management, and real-time event dashboards for developers.sap.com", + "policyLevels": [ + "sap:core:v1" + ], + "defaultVisibility": "public" + }, + "[development]": { + "swagger": { + "basePath": "/$api-docs", + "diagram": true + } + }, + "mcp": { + "per_action_tool": true + } + } +} diff --git a/srv/lib/__tests__/deploy-alerts-config.test.js b/srv/lib/__tests__/deploy-alerts-config.test.js new file mode 100644 index 000000000..e7f1db071 --- /dev/null +++ b/srv/lib/__tests__/deploy-alerts-config.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { resolveChannels } from '@sap-tutorials/cds-alert-notification/lib/routing.js'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); +const cfg = pkg.cds.requires.alerts; + +describe('deploy alerts routing config', () => { + it('declares the dedicated deploy channel', () => { + expect(cfg.channels).toContain('email:devrel-deploys'); + }); + it('registers the three deploy eventTypes', () => { + for (const t of ['DeployStarted', 'DeployFinished', 'DeployFailed']) { + expect(cfg.eventTypes).toContain(t); + } + }); + it('routes NOTICE-level deploy chatter to the deploys channel only', () => { + const ch = resolveChannels('NOTICE', cfg); + expect(ch).toContain('email:devrel-deploys'); + expect(ch).not.toContain('email:devrel-oncall'); + }); + it('routes ERROR-level failures to BOTH deploys and on-call', () => { + const ch = resolveChannels('ERROR', cfg); + expect(ch).toContain('email:devrel-deploys'); + expect(ch).toContain('email:devrel-oncall'); + }); +}); From 2f11163a6d4b0f9c64d5496bec281c73eb6f91a6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:15:36 -0400 Subject: [PATCH 4/8] feat(alerts): POST /ops/deploy-event route mapping phase to ANS alert --- srv/routes/__tests__/deploy-events.test.js | 72 ++++++++++++++++++++++ srv/routes/deploy-events.js | 51 +++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 srv/routes/__tests__/deploy-events.test.js create mode 100644 srv/routes/deploy-events.js diff --git a/srv/routes/__tests__/deploy-events.test.js b/srv/routes/__tests__/deploy-events.test.js new file mode 100644 index 000000000..852ffb150 --- /dev/null +++ b/srv/routes/__tests__/deploy-events.test.js @@ -0,0 +1,72 @@ +// srv/routes/__tests__/deploy-events.test.js +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'node:http'; +import express from 'express'; + +// Mock the fail-open alerting module so no ANS/DB is needed. +const raiseMock = vi.fn(() => Promise.resolve()); +vi.mock('../../lib/alerting.js', () => ({ raise: (...a) => raiseMock(...a) })); + +const { register, phaseToPayload } = await import('../deploy-events.js'); + +let server, baseUrl; +beforeAll(async () => { + const app = express(); + register(app); // no authMw → handler runs unguarded + server = http.createServer(app); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); +afterAll(async () => { await new Promise((r) => server?.close(r)); }); + +function post(body) { + return fetch(`${baseUrl}/ops/deploy-event`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('phaseToPayload', () => { + it('maps start → DeployStarted / NOTICE', () => { + const p = phaseToPayload('start', { env: 'prod', version: '1.2.3' }); + expect(p.eventType).toBe('DeployStarted'); + expect(p.severity).toBe('NOTICE'); + expect(p.resource).toEqual({ resourceName: 'deploy-prod', resourceType: 'deployment' }); + expect(p.subject).toContain('prod'); + expect(p.subject).toContain('1.2.3'); + }); + it('maps end → DeployFinished / NOTICE', () => { + expect(phaseToPayload('end', { env: 'dev' }).eventType).toBe('DeployFinished'); + expect(phaseToPayload('end', { env: 'dev' }).severity).toBe('NOTICE'); + }); + it('maps fail → DeployFailed / ERROR with detail in body', () => { + const p = phaseToPayload('fail', { env: 'qa', detail: 'smoke gate failed' }); + expect(p.eventType).toBe('DeployFailed'); + expect(p.severity).toBe('ERROR'); + expect(p.body).toContain('smoke gate failed'); + }); +}); + +describe('POST /ops/deploy-event', () => { + it('202 + raises alert for a valid start', async () => { + raiseMock.mockClear(); + const res = await post({ phase: 'start', env: 'prod', version: '9.9.9' }); + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 10)); // let the void raise() settle + expect(raiseMock).toHaveBeenCalledOnce(); + expect(raiseMock.mock.calls[0][0].eventType).toBe('DeployStarted'); + }); + it('400 on missing/invalid phase, no raise', async () => { + raiseMock.mockClear(); + const res = await post({ env: 'prod' }); + expect(res.status).toBe(400); + expect(raiseMock).not.toHaveBeenCalled(); + }); + it('still 202 when raise rejects (fail-open)', async () => { + raiseMock.mockClear(); + raiseMock.mockImplementationOnce(() => Promise.reject(new Error('ANS down'))); + const res = await post({ phase: 'end', env: 'dev' }); + expect(res.status).toBe(202); + }); +}); diff --git a/srv/routes/deploy-events.js b/srv/routes/deploy-events.js new file mode 100644 index 000000000..ad5d5eab5 --- /dev/null +++ b/srv/routes/deploy-events.js @@ -0,0 +1,51 @@ +// srv/routes/deploy-events.js +// +// POST /ops/deploy-event — operational endpoint pinged by scripts/deploy-mta.cjs +// at each deploy lifecycle boundary (start/end/fail). Bearer-guarded upstream by +// contentAuthMiddleware (CONTENT_API_KEY). Fail-open: always 202 on a well-formed +// request; alerting.raise is fire-and-forget and never blocks the response. +// Spec: docs/superpowers/specs/2026-08-04-deploy-lifecycle-alerts-design.md +import cds from '@sap/cds'; +import express from 'express'; +import * as alerting from '../lib/alerting.js'; + +const LOG = cds.log('deploy-events'); + +const PHASE_MAP = { + start: { eventType: 'DeployStarted', severity: 'NOTICE', verb: 'started' }, + end: { eventType: 'DeployFinished', severity: 'NOTICE', verb: 'finished' }, + fail: { eventType: 'DeployFailed', severity: 'ERROR', verb: 'FAILED' }, +}; + +export function phaseToPayload(phase, { env, version, detail } = {}) { + const m = PHASE_MAP[phase]; + if (!m) return null; + const envLabel = env || 'unknown'; + const verSuffix = version ? ` ${version}` : ''; + return { + eventType: m.eventType, + severity: m.severity, + category: 'ALERT', + subject: `Deploy ${m.verb} — ${envLabel}${verSuffix}`, + body: detail || `Deploy ${m.verb} for ${envLabel}${verSuffix}.`, + resource: { resourceName: `deploy-${envLabel}`, resourceType: 'deployment' }, + }; +} + +async function handler(req, res) { + const { phase, env, version, detail } = req.body || {}; + const payload = phaseToPayload(phase, { env, version, detail }); + if (!payload) { + return res.status(400).json({ error: 'invalid or missing "phase" (start|end|fail)' }); + } + // Fire-and-forget; alerting.raise is itself fail-open. Never block the deploy. + void alerting.raise(payload); + LOG.info(`deploy-event ${phase} env=${env ?? '?'} version=${version ?? '?'}`); + return res.status(202).json({ ok: true }); +} + +export function register(app, { authMw } = {}) { + const parse = express.json({ limit: '16kb' }); + const chain = authMw ? [parse, authMw, handler] : [parse, handler]; + app.post('/ops/deploy-event', ...chain); +} From cdf2f101d9bcdbd25380c6508398e9ea389e86d8 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:19:04 -0400 Subject: [PATCH 5/8] fix(alerts): handle raise() rejection explicitly; assert category in test --- srv/routes/__tests__/deploy-events.test.js | 3 ++- srv/routes/deploy-events.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/srv/routes/__tests__/deploy-events.test.js b/srv/routes/__tests__/deploy-events.test.js index 852ffb150..cefbe6feb 100644 --- a/srv/routes/__tests__/deploy-events.test.js +++ b/srv/routes/__tests__/deploy-events.test.js @@ -35,6 +35,7 @@ describe('phaseToPayload', () => { expect(p.resource).toEqual({ resourceName: 'deploy-prod', resourceType: 'deployment' }); expect(p.subject).toContain('prod'); expect(p.subject).toContain('1.2.3'); + expect(p.category).toBe('ALERT'); }); it('maps end → DeployFinished / NOTICE', () => { expect(phaseToPayload('end', { env: 'dev' }).eventType).toBe('DeployFinished'); @@ -53,7 +54,7 @@ describe('POST /ops/deploy-event', () => { raiseMock.mockClear(); const res = await post({ phase: 'start', env: 'prod', version: '9.9.9' }); expect(res.status).toBe(202); - await new Promise((r) => setTimeout(r, 10)); // let the void raise() settle + await new Promise((r) => setTimeout(r, 10)); // let the fire-and-forget raise() settle expect(raiseMock).toHaveBeenCalledOnce(); expect(raiseMock.mock.calls[0][0].eventType).toBe('DeployStarted'); }); diff --git a/srv/routes/deploy-events.js b/srv/routes/deploy-events.js index ad5d5eab5..bffcd9203 100644 --- a/srv/routes/deploy-events.js +++ b/srv/routes/deploy-events.js @@ -39,7 +39,7 @@ async function handler(req, res) { return res.status(400).json({ error: 'invalid or missing "phase" (start|end|fail)' }); } // Fire-and-forget; alerting.raise is itself fail-open. Never block the deploy. - void alerting.raise(payload); + alerting.raise(payload).catch((err) => LOG.warn('deploy-event raise failed (swallowed):', err?.message ?? err)); LOG.info(`deploy-event ${phase} env=${env ?? '?'} version=${version ?? '?'}`); return res.status(202).json({ ok: true }); } From 2f455ddaba8ceda7fab303836737bcc38c9c5903 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:24:04 -0400 Subject: [PATCH 6/8] feat(alerts): mount /ops/deploy-event with CONTENT_API_KEY auth --- srv/server.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/srv/server.js b/srv/server.js index 29aede262..49e1cbcad 100644 --- a/srv/server.js +++ b/srv/server.js @@ -35,6 +35,7 @@ import * as devtoberfestPublic from './routes/devtoberfest-public.js'; import * as devtoberfestSchedule from './routes/devtoberfest-schedule.js'; import * as devtoberfestAuth from './routes/devtoberfest-auth.js'; import * as alertsPublic from './routes/alerts-public.js'; +import * as deployEvents from './routes/deploy-events.js'; import { invalidate as invalidateAlertsCache } from './lib/alerts-cache.js'; import { resolveUser, captureUserMiddleware } from './lib/resolve-user.js'; import { patMiddleware, pinPatUserToContext } from './lib/mcp-pat-middleware.js'; @@ -465,6 +466,11 @@ cds.on('bootstrap', (app) => { // going red in an unwatched CI tab. Same auth as /content/publish. app.post('/content/pipeline-log', express.json({ limit: '256kb' }), contentAuthMiddleware, pipelineLogFailureHandler); + // Deploy lifecycle alerts (#deploy-alerts): scripts/deploy-mta.cjs pings this + // at start/end/fail of a deploy → ANS. Same bearer auth (CONTENT_API_KEY) as + // the other ops endpoints. Body parser is applied inside register(). + deployEvents.register(app, { authMw: contentAuthMiddleware }); + // Analytics Builder Phase 1 — streaming CSV export. Mounted later in this // bootstrap block (after contextMw/authMw are defined) so req.user is // populated by CAP's auth chain before the handler runs. From 7e61f7765a0dc2b589fe2ec964d96c9b499e0026 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:34:01 -0400 Subject: [PATCH 7/8] feat(alerts): ping /ops/deploy-event at deploy start/end/fail - Add notifyDeploy() async helper to scripts/deploy-mta.cjs: best-effort POST to ${cfg.srvUrl}/ops/deploy-event with Bearer auth, 5s AbortController timeout, never throws, no new npm deps (native fetch + AbortController). - Wire deployVersion (from writeVersionFile or readMtaVersion on --skip-build) and fire at: start (before cf deploy), fail (cf deploy failure + smoke gate failure), end (after smoke passes). Blue-green paused path logs a warn that no auto "end" will fire. - Guard require.main === module so tests can require() without running main(). - Export { notifyDeploy } for the test seam. - Add scripts/__tests__/notify-deploy.test.js (3 tests: POST payload+auth, network-error never-rejects, no-op when apiKey absent). - Fix vitest.config.ts unit project include patterns to match .test.js (was .test.ts only for scripts/__tests__/) and add globals:true so CJS test files get describe/it/expect/vi injected. --- scripts/__tests__/notify-deploy.test.js | 30 ++++++++++++++++ scripts/deploy-mta.cjs | 47 +++++++++++++++++++++++-- vitest.config.ts | 3 +- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 scripts/__tests__/notify-deploy.test.js diff --git a/scripts/__tests__/notify-deploy.test.js b/scripts/__tests__/notify-deploy.test.js new file mode 100644 index 000000000..376de5996 --- /dev/null +++ b/scripts/__tests__/notify-deploy.test.js @@ -0,0 +1,30 @@ +// scripts/__tests__/notify-deploy.test.js +// vitest globals (describe, it, expect, vi) are injected by vitest's +// `globals: true` config — no require('vitest') needed in CJS test files. +const { notifyDeploy } = require('../deploy-mta.cjs'); + +const CFG = { srvUrl: 'https://srv.example.com' }; + +describe('notifyDeploy (best-effort)', () => { + it('POSTs the phase payload with bearer auth', async () => { + const calls = []; + const fetchImpl = (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true, status: 202 }); }; + await notifyDeploy('start', CFG, { env: 'prod', version: '1.0.0' }, { fetchImpl, apiKey: 'k', log: () => {} }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://srv.example.com/ops/deploy-event'); + expect(calls[0].opts.headers.Authorization).toBe('Bearer k'); + const body = JSON.parse(calls[0].opts.body); + expect(body).toMatchObject({ phase: 'start', env: 'prod', version: '1.0.0' }); + }); + it('never rejects on network error', async () => { + const fetchImpl = () => Promise.reject(new Error('ECONNREFUSED')); + await expect( + notifyDeploy('end', CFG, { env: 'dev' }, { fetchImpl, apiKey: 'k', log: () => {} }) + ).resolves.toBeUndefined(); + }); + it('no-ops (no fetch) when apiKey is absent', async () => { + const fetchImpl = vi.fn(); + await notifyDeploy('start', CFG, { env: 'dev' }, { fetchImpl, apiKey: '', log: () => {} }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/deploy-mta.cjs b/scripts/deploy-mta.cjs index 9dd671cc8..7dee62c1c 100644 --- a/scripts/deploy-mta.cjs +++ b/scripts/deploy-mta.cjs @@ -119,6 +119,37 @@ function shCapture(cmd, args, opts = {}) { return { status: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' }; } +// --------------------------------------------------------------------------- +// Deploy lifecycle alert ping (best-effort). POSTs to the srv's +// /ops/deploy-event, which raises an ANS alert. NEVER throws, NEVER changes the +// deploy exit code — a down/misconfigured alerting path must not block a deploy. +// deps is a test seam: { fetchImpl, apiKey, log }. +// --------------------------------------------------------------------------- +async function notifyDeploy(phase, cfg, extra = {}, deps = {}) { + const fetchImpl = deps.fetchImpl || globalThis.fetch; + const apiKey = deps.apiKey !== undefined ? deps.apiKey : process.env.CONTENT_API_KEY; + const logFn = deps.log || warn; + if (!apiKey) { + logFn(`deploy-event ${phase}: CONTENT_API_KEY not set — skipping alert ping`); + return; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetchImpl(`${cfg.srvUrl}/ops/deploy-event`, { + method: 'POST', + headers: { 'content-type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ phase, ...extra }), + signal: controller.signal, + }); + if (!res.ok) logFn(`deploy-event ${phase}: srv returned ${res.status} (ignored)`); + } catch (e) { + logFn(`deploy-event ${phase}: ping failed (ignored) — ${e.message ?? e}`); + } finally { + clearTimeout(timer); + } +} + // --------------------------------------------------------------------------- // Step 0: preconditions // --------------------------------------------------------------------------- @@ -255,7 +286,7 @@ function abortFailedBlueGreen() { } } -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -278,6 +309,7 @@ function main() { } const envName = args.env; const cfg = ENVS[envName]; + let deployVersion; console.log(C.cyn('\n══════════════════════════════════════════════════════')); console.log(C.cyn(` Deploy → ${envName.toUpperCase()}`) + (args.dryRun ? C.ylw(' (DRY RUN)') : '') + (args.strategy !== 'default' ? C.ylw(` [${args.strategy}]`) : '')); @@ -317,10 +349,12 @@ function main() { step(1.5, 'Write srv/version.json (build metadata for GET /version)'); if (args.skipBuild) { warn('--skip-build: leaving the existing srv/version.json (baked into the reused mtar).'); + deployVersion = readMtaVersion(); } else if (args.dryRun) { warn(`dry-run: would write ${path.relative(ROOT, VERSION_FILE)} from .deploy/mta.yaml version + git sha`); } else { const v = writeVersionFile(); + deployVersion = v.version; ok(`wrote srv/version.json — version ${v.version} · commit ${v.sha} · built ${v.builtAt}`); } @@ -406,9 +440,11 @@ function main() { // Windows git-bash does not expand it and cf.exe panics (issue #1226). const mtar = newestMtarPath(); if (!mtar) die(1, `no .mtar found in ${path.relative(ROOT, MTAR_GLOB_DIR)} to deploy. Run without --skip-build, or build the mtar first.`); + await notifyDeploy('start', cfg, { env: envName, version: deployVersion }); const code = sh('cf', ['deploy', mtar, '-e', mtaext, ...strategyFlags, '-f'], { cwd: DEPLOY_DIR }); if (code !== 0) { if (bg) abortFailedBlueGreen(); + await notifyDeploy('fail', cfg, { env: envName, version: deployVersion, detail: 'cf deploy failed' }); die(1, '`cf deploy` failed. Check `cf logs` and the deployer output above.'); } if (bg) { @@ -417,6 +453,7 @@ function main() { warn(' cf mta-ops # find the RUNNING op id'); warn(' cf deploy -i -a resume # swap to green + retire blue'); warn(' cf deploy -i -a abort # discard green, keep blue'); + warn('No automatic "deploy finished" alert will fire for blue-green (paused before swap).'); } else { ok(`cf deploy complete (${mtar})`); } @@ -445,9 +482,11 @@ function main() { if (code !== 0) { console.error('\n' + C.red('[deploy] SMOKE GATE FAILED') + ' — the deploy landed but a post-deploy check regressed.'); console.error(C.red(' Treat the deployed env as BROKEN until this is triaged.')); + await notifyDeploy('fail', cfg, { env: envName, version: deployVersion, detail: 'smoke gate failed' }); process.exit(2); } ok('smoke tests passed — deploy verified'); + await notifyDeploy('end', cfg, { env: envName, version: deployVersion }); } console.log('\n' + C.grn('══════════════════════════════════════════════════════')); @@ -458,4 +497,8 @@ function main() { console.log(C.grn('══════════════════════════════════════════════════════') + '\n'); } -main(); +if (require.main === module) { + main(); +} + +module.exports = { notifyDeploy }; diff --git a/vitest.config.ts b/vitest.config.ts index 15fd04b19..22d0479d6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,8 +39,9 @@ export default defineConfig({ plugins: [vue()], test: { name: 'unit', + globals: true, environment: 'node', - include: ['test/**/*.test.{js,ts}', 'scripts/__tests__/**/*.test.ts', 'scripts/**/__tests__/**/*.test.ts', 'srv/**/__tests__/**/*.test.{js,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}'], + include: ['test/**/*.test.{js,ts}', 'scripts/__tests__/**/*.test.{js,ts}', 'scripts/**/__tests__/**/*.test.{js,ts}', 'srv/**/__tests__/**/*.test.{js,ts}', 'app/analytics-explorer/src/**/__tests__/**/*.test.ts', 'app/explore/src/**/__tests__/**/*.test.ts', 'hugo-apps/src/**/*.{test,spec}.{js,ts}'], // test/e2e/** is the Playwright-driven `e2e` project (below). Its // specs launch a real browser against a DEPLOYED approuter and would // hang the unit tier (which has no BASE_URL); the broad From ac99993e4dd5fae42f85e78ef64f7346509fe954 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Tue, 4 Aug 2026 11:44:06 -0400 Subject: [PATCH 8/8] chore(alerts): bump MTA version + document deploy lifecycle alerts --- .deploy/mta.yaml | 2 +- docs/developers/operations/mta-deployment.md | 24 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index b10f43ff0..008662d80 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.10.0 +version: 1.11.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/docs/developers/operations/mta-deployment.md b/docs/developers/operations/mta-deployment.md index 5dffdba6f..bfbdd52fd 100644 --- a/docs/developers/operations/mta-deployment.md +++ b/docs/developers/operations/mta-deployment.md @@ -509,3 +509,27 @@ The `before-all` build hook in `.deploy/mta.yaml` copies app builds into the app Should show ONE file. Two = ghost. 3. Long-term fix worth proposing: prepend the static-dir copy with `rm -rf static/admin-ui` (or per-component `rm -rf static/admin-ui/components/`). Adds ~100ms to build, eliminates the ghost-file class of bug entirely. Same hazard exists for analytics-ui, scanner-ui, display-app cp lines. + +## Deploy lifecycle alerts (start / end / fail) + +`npm run deploy -- --env ` pings the deployed srv's `POST /ops/deploy-event` +(bearer `CONTENT_API_KEY`) at three points: **start** (before `cf deploy`), +**end** (after the smoke gate passes), and **fail** (on a `cf deploy` or +smoke-gate failure). The srv raises an SAP Alert Notification event: + +- `DeployStarted` / `DeployFinished` → severity `NOTICE` → `email:devrel-deploys`. +- `DeployFailed` → severity `ERROR` → `email:devrel-deploys` **and** `email:devrel-oncall`. + +**Prerequisites for delivery:** +1. `ChatSettings.alertsEnabled` must be ON in the target env (admin UI `/admin-ui`). + Default is OFF. (Note: a deploy that flips this flag can suppress its own + end/fail ping — accepted edge case.) +2. The `devrel-deploys` channel's email **action** must be provisioned in ANS + (cockpit / `gen/alerts/provision.sh`) with the real distribution-list address, + exactly like `devrel-oncall`. +3. `CONTENT_API_KEY` must be present in the operator/CI environment running the + deploy (it already is, for content publish). + +**Blue-green caveat:** a `--strategy blue-green` deploy pauses before the traffic +swap and exits, so it emits **start** and (on failure) **fail**, but NOT an +automatic **finished** — the swap happens later via `cf deploy -i -a resume`.