@metasession.co/devaudit-cli 0.1.61 → 0.1.63

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": "@metasession.co/devaudit-cli",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^0.8.2",
36
- "@metasession.co/devaudit-plugin-sdk": "^0.1.61",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.1.63",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -244,6 +244,13 @@ MAX_ATTEMPTS=${UPLOAD_MAX_ATTEMPTS:-5}
244
244
  INITIAL_BACKOFF_SECONDS=${UPLOAD_INITIAL_BACKOFF_SECONDS:-1}
245
245
  UPLOAD_CONNECT_TIMEOUT_SECONDS=${UPLOAD_CONNECT_TIMEOUT_SECONDS:-10}
246
246
  UPLOAD_MAX_TIME_SECONDS=${UPLOAD_MAX_TIME_SECONDS:-120}
247
+ # DevAudit-Installer#189 — files above this size use the presigned R2 URL
248
+ # upload flow (3-step: request URL → PUT to R2 → notify portal) instead of
249
+ # the multipart POST. Avoids the portal's FormData body parser limit.
250
+ # 25MB = 26214400 bytes.
251
+ PRESIGNED_THRESHOLD_BYTES=${PRESIGNED_THRESHOLD:-26214400}
252
+ PRESIGNED_MAX_ATTEMPTS=${PRESIGNED_MAX_ATTEMPTS:-3}
253
+ PRESIGNED_UPLOAD_MAX_TIME_SECONDS=${PRESIGNED_UPLOAD_MAX_TIME_SECONDS:-300}
247
254
 
248
255
  is_unedited_starter_stub() {
249
256
  # Match BOTH banner phrasings the SDLC has shipped (v0.1.36 changed
@@ -253,6 +260,153 @@ is_unedited_starter_stub() {
253
260
  grep -aqE 'STARTER TEMPLATE.+REPLACE BEFORE' "$1"
254
261
  }
255
262
 
263
+ # DevAudit-Installer#189 — Presigned R2 URL upload for large files (>25MB).
264
+ # 3-step flow: (1) request presigned URL from portal, (2) PUT file to R2,
265
+ # (3) notify portal that upload is complete. Each step retries on 429/5xx
266
+ # and connection errors. Returns 0 on success, 1 on failure.
267
+ upload_presigned() {
268
+ local file="$1"
269
+ local filename
270
+ filename=$(basename "$file")
271
+ local file_size
272
+ file_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
273
+
274
+ # Derive a MIME type from the extension (best-effort).
275
+ local mime_type="application/octet-stream"
276
+ case "$filename" in
277
+ *.zip) mime_type="application/zip" ;;
278
+ *.json) mime_type="application/json" ;;
279
+ *.png) mime_type="image/png" ;;
280
+ *.pdf) mime_type="application/pdf" ;;
281
+ *.md) mime_type="text/markdown" ;;
282
+ *.html) mime_type="text/html" ;;
283
+ esac
284
+
285
+ local presign_url="${DEVAUDIT_BASE_URL}/api/evidence/upload-url"
286
+ local complete_url="${DEVAUDIT_BASE_URL}/api/evidence/upload-complete"
287
+
288
+ # --- Step 1: Request presigned upload URL ---
289
+ local attempt backoff http_code curl_exit resp_body upload_url evidence_id
290
+ attempt=1
291
+ backoff=$INITIAL_BACKOFF_SECONDS
292
+ upload_url=""
293
+ evidence_id=""
294
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
295
+ resp_body=$(mktemp)
296
+ http_code=$(curl -s -o "$resp_body" -w "%{http_code}" \
297
+ -X POST -L --max-redirs 3 \
298
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
299
+ --max-time "$UPLOAD_MAX_TIME_SECONDS" \
300
+ "$presign_url" \
301
+ -H "Authorization: Bearer ${DEVAUDIT_API_KEY}" \
302
+ -H "Content-Type: application/json" \
303
+ -d "{
304
+ \"projectSlug\": \"${PROJECT_SLUG}\",
305
+ \"requirementId\": \"${REQUIREMENT_ID}\",
306
+ \"evidenceType\": \"${EVIDENCE_TYPE}\",
307
+ \"fileName\": \"${filename}\",
308
+ \"fileSizeBytes\": ${file_size},
309
+ \"mimeType\": \"${mime_type}\",
310
+ \"metadata\": ${METADATA},
311
+ \"releaseVersion\": \"${RELEASE_VERSION}\",
312
+ \"createReleaseIfMissing\": \"${CREATE_RELEASE_IF_MISSING}\",
313
+ \"releaseBranch\": \"${BRANCH}\",
314
+ \"environment\": \"${ENVIRONMENT}\",
315
+ \"evidenceCategory\": \"${EVIDENCE_CATEGORY}\",
316
+ \"sdlcStage\": \"${SDLC_STAGE}\"
317
+ }") || curl_exit=$?
318
+ curl_exit=${curl_exit:-0}
319
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
320
+ upload_url=$(jq -r '.uploadUrl // empty' "$resp_body" 2>/dev/null || true)
321
+ evidence_id=$(jq -r '.evidenceId // empty' "$resp_body" 2>/dev/null || true)
322
+ rm -f "$resp_body"
323
+ if [ -n "$upload_url" ] && [ -n "$evidence_id" ] && [ "$upload_url" != "null" ] && [ "$evidence_id" != "null" ]; then
324
+ break
325
+ fi
326
+ # Portal responded 2xx but didn't return presigned URL fields —
327
+ # presigned URL flow not configured. Fall back to multipart.
328
+ echo -n "(portal did not return presigned URL, falling back to multipart) "
329
+ return 255
330
+ fi
331
+ rm -f "$resp_body"
332
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
333
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
334
+ echo -n "(step 1: HTTP ${http_code}, retry in ${backoff}s) "
335
+ sleep "$backoff"
336
+ attempt=$((attempt + 1))
337
+ backoff=$((backoff * 2))
338
+ continue
339
+ fi
340
+ fi
341
+ # Non-retriable error (4xx other than 429).
342
+ echo -n "(step 1 failed: HTTP ${http_code}) "
343
+ return 1
344
+ done
345
+
346
+ if [ -z "$upload_url" ] || [ -z "$evidence_id" ]; then
347
+ echo -n "(step 1: no presigned URL after ${attempt} attempts) "
348
+ return 1
349
+ fi
350
+
351
+ # --- Step 2: Upload directly to R2 via presigned URL ---
352
+ attempt=1
353
+ backoff=$INITIAL_BACKOFF_SECONDS
354
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
355
+ http_code=$(curl -s -o /dev/null -w "%{http_code}" \
356
+ -X PUT \
357
+ -H "Content-Type: ${mime_type}" \
358
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
359
+ --max-time "$PRESIGNED_UPLOAD_MAX_TIME_SECONDS" \
360
+ --data-binary @"$file" \
361
+ "$upload_url") || curl_exit=$?
362
+ curl_exit=${curl_exit:-0}
363
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
364
+ break
365
+ fi
366
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
367
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
368
+ echo -n "(step 2: HTTP ${http_code}, retry in ${backoff}s) "
369
+ sleep "$backoff"
370
+ attempt=$((attempt + 1))
371
+ backoff=$((backoff * 2))
372
+ continue
373
+ fi
374
+ fi
375
+ echo -n "(step 2 failed: HTTP ${http_code}) "
376
+ return 1
377
+ done
378
+
379
+ # --- Step 3: Notify portal that upload is complete ---
380
+ attempt=1
381
+ backoff=$INITIAL_BACKOFF_SECONDS
382
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
383
+ http_code=$(curl -s -o /dev/null -w "%{http_code}" \
384
+ -X POST -L --max-redirs 3 \
385
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
386
+ --max-time "$UPLOAD_MAX_TIME_SECONDS" \
387
+ "$complete_url" \
388
+ -H "Authorization: Bearer ${DEVAUDIT_API_KEY}" \
389
+ -H "Content-Type: application/json" \
390
+ -d "{\"evidenceId\": \"${evidence_id}\"}") || curl_exit=$?
391
+ curl_exit=${curl_exit:-0}
392
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
393
+ return 0
394
+ fi
395
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
396
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
397
+ echo -n "(step 3: HTTP ${http_code}, retry in ${backoff}s) "
398
+ sleep "$backoff"
399
+ attempt=$((attempt + 1))
400
+ backoff=$((backoff * 2))
401
+ continue
402
+ fi
403
+ fi
404
+ echo -n "(step 3 failed: HTTP ${http_code}) "
405
+ return 1
406
+ done
407
+ return 1
408
+ }
409
+
256
410
  for FILE in "${FILES[@]}"; do
257
411
  FILENAME=$(basename "$FILE")
258
412
  if is_unedited_starter_stub "$FILE"; then
@@ -262,6 +416,27 @@ for FILE in "${FILES[@]}"; do
262
416
  fi
263
417
  FILE_SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE")
264
418
  echo -n "Uploading ${FILENAME}... "
419
+
420
+ # DevAudit-Installer#189 — large files use the presigned R2 URL flow
421
+ # to bypass the portal's multipart body parser limit. If the portal
422
+ # doesn't support presigned URLs (returns 255), fall back to multipart.
423
+ if [ "$FILE_SIZE" -ge "$PRESIGNED_THRESHOLD_BYTES" ]; then
424
+ PRESIGNED_RESULT=0
425
+ upload_presigned "$FILE" || PRESIGNED_RESULT=$?
426
+ if [ "$PRESIGNED_RESULT" -eq 0 ]; then
427
+ echo "OK (${FILE_SIZE} bytes, presigned R2 upload)"
428
+ SUCCEEDED=$((SUCCEEDED + 1))
429
+ TOTAL_SIZE=$((TOTAL_SIZE + FILE_SIZE))
430
+ continue
431
+ elif [ "$PRESIGNED_RESULT" -ne 255 ]; then
432
+ echo "FAILED (presigned upload after ${PRESIGNED_MAX_ATTEMPTS} attempts)"
433
+ FAILED=$((FAILED + 1))
434
+ continue
435
+ fi
436
+ # Result 255 — portal doesn't support presigned URLs, fall through
437
+ # to the existing multipart flow.
438
+ fi
439
+
265
440
  # `-L` follows 3xx redirects (devaudit-installer#143). The portal host
266
441
  # has moved before (devaudit.metasession.co → devaudit.ai); without -L
267
442
  # every consumer's CI silently fails on a stale base URL. `--max-redirs 3`
@@ -189,6 +189,30 @@ For **visual regression** specifically:
189
189
 
190
190
  Do additions, updates, and (approved) deletions in the same change so the suite stays internally consistent.
191
191
 
192
+ ### Phase 5½ — Evidence wiring validation
193
+
194
+ Before running the suite (Phase 6), verify that the evidence traceability wiring is in place. A spec can pass Phase 6's "AC covered" check by having a correct assertion without ever calling `evidenceShot()` or tagging `@requirement` — producing zero portal evidence and letting the release reach UAT with no traceable screenshots (DevAudit-Installer #170, #169).
195
+
196
+ **For each in-scope REQ and its ACs from Phase 2's scenario table:**
197
+
198
+ 1. **Check `@requirement` annotation.** Grep the authored/modified spec files for `@requirement REQ-XXX` tags. Every spec file that covers an in-scope REQ must carry at least one `@requirement REQ-XXX` annotation so the CI's `detect-req` step and the portal's evidence-by-requirement view can find it.
199
+
200
+ 2. **Check `evidenceShot()` calls.** For each UI spec covering an in-scope REQ, grep for at least one `evidenceShot(page, 'REQ-XXX', <ac>, ...)` call per AC. The call must be placed **at the assertion that proves the AC**, before any further interaction or navigation. API-only specs that don't have a visual surface are exempt — note the exemption in the test-execution-summary.
201
+
202
+ 3. **Check test title annotations.** Each test for an in-scope REQ must carry a `[REQ-XXX]` tag in the test title or `test.info().annotations` so the Playwright JSON reporter emits the REQ association. The portal uses this to map test results to requirements.
203
+
204
+ **If any check fails:**
205
+
206
+ Halt and report the gap to the user:
207
+ > Evidence wiring incomplete for REQ-XXX:
208
+ > - Missing `@requirement REQ-XXX` annotation in `e2e/<area>/foo.spec.ts`
209
+ > - Missing `evidenceShot()` call for AC2 in `e2e/<area>/bar.spec.ts`
210
+ > - Missing `[REQ-XXX]` tag in test title for "should filter by status"
211
+ >
212
+ > These must be fixed before running the suite — without them the portal will show zero screenshots and zero tagged tests for this REQ, and the CI gate (#169) will block the release.
213
+
214
+ Do **not** proceed to Phase 6 until all gaps are resolved. The user may choose to skip an AC (e.g. API-only, transport-only) — that's valid, but it must be an explicit decision recorded in the test-execution-summary, not an omission.
215
+
192
216
  ### Phase 6 — Execute and report
193
217
 
194
218
  Run the suite. Strategy:
@@ -59,6 +59,39 @@ The only pauses in the whole workflow are the explicitly-named checkpoints:
59
59
 
60
60
  Everything else is silent continuation. The rule is **opt-in-to-pause, not opt-out-of-pause**. If you find yourself stopping after a sub-skill's "Return to the running `sdlc-implementer` context" line and waiting for the operator to ask _"is anything happening?"_ — that is the bug this section exists to prevent. Keep going.
61
61
 
62
+ ## Scope-expansion halt gate (devaudit-installer#171)
63
+
64
+ The change-request loop's scope-expansion halt rule (see [`references/change-request-loop.md`](./references/change-request-loop.md) §"If the change-request is fundamentally a different REQ") only fires during Phase 5 — after a UAT reviewer clicks "Request Changes" and the portal state is `uat_changes_requested`. In practice, scope-expanding requests also arrive from the user at any point after Phase 1:
65
+
66
+ - During Phase 2 (implementation) — user spots a related improvement and asks the agent to add it
67
+ - During Phase 3 (evidence compile) — user asks for an additional evidence artefact not in the plan
68
+ - During Phase 4 (UAT submission) — user exercises the deployed app and asks for a behaviour change
69
+ - Between UAT submission and approval — same scenario
70
+ - After approval but before merge — user makes a last-minute request
71
+
72
+ In all these cases, the portal state is NOT `uat_changes_requested`, so the change-request loop's halt rule never fires. The agent implements the out-of-scope change without flagging it.
73
+
74
+ **This gate fires on every user work request while a REQ is active, in any phase.**
75
+
76
+ When the user asks for a change that goes beyond the current REQ's acceptance criteria:
77
+
78
+ 1. **Read the current REQ's `test-scope.md`** (or `implementation-plan.md` § Acceptance Criteria if no test-scope exists yet) to identify the defined ACs.
79
+
80
+ 2. **Check whether the user's request maps to an existing AC.** If yes — proceed (or enter the change-request loop if in Phase 5).
81
+
82
+ 3. **If no — halt with the scope-expansion message:**
83
+
84
+ > This request adds behaviour outside REQ-XXX's acceptance criteria (AC1–ACn defined in test-scope.md). Recommend filing a separate issue for \<subject\> and shipping REQ-XXX as originally scoped.
85
+
86
+ 4. **Wait for the user to confirm one of:**
87
+ - **(a) File a separate issue** (new REQ) — ship the current REQ as-is. The agent continues with the original scope.
88
+ - **(b) Amend REQ-XXX's scope** — explicitly expand `test-scope.md` / `implementation-plan.md`, update the plan, invalidate existing evidence, and re-walk Stage 3. This option carries a warning: *"Amending scope after evidence is compiled (Stage 3+) invalidates the existing test-execution-summary, screenshots, and UAT verification. All Stage 3 evidence must be re-compiled."*
89
+ - **(c) Abandon the request** — do nothing, continue with the original scope.
90
+
91
+ **Do not implement the out-of-scope change before the user picks (a), (b), or (c).** The inertia trap is real: the agent is mid-flow, the codebase is open, and the request sounds reasonable. The gate exists to interrupt that inertia — STOP, surface the scope gap, and let the user decide.
92
+
93
+ This gate is distinct from Phase 2 step 4's "any deviation from the plan must be noted" rule: that covers implementation *approach* deviations (how to build something the plan says to build), not *scope* expansions (building something the plan doesn't say to build at all).
94
+
62
95
  ## SDLC navigability — LAST/NEXT status sticky (devaudit#131)
63
96
 
64
97
  Long-running SDLC issues accumulate dozens of comments across multiple Claude Code sessions. The operator returning to the thread should be able to answer two questions in under five seconds:
@@ -549,6 +549,8 @@ jobs:
549
549
  # compliance/pending-releases/. Failures now bump UPLOAD_FAILURES
550
550
  # so the step exits non-zero and the release PR turns red.
551
551
  SHOT_REQS=()
552
+ ZERO_Screenshot_REQS=()
553
+ EVIDENCE_GAPS=0
552
554
  if [ -d compliance/pending-releases ]; then
553
555
  for TICKET in compliance/pending-releases/RELEASE-TICKET-REQ-*.md; do
554
556
  [ -f "$TICKET" ] || continue
@@ -580,6 +582,12 @@ jobs:
580
582
  REQ_SHOTS=(ci-evidence/compliance/evidence/"$REQ"/screenshots/*.png)
581
583
  if [ "${#REQ_SHOTS[@]}" -eq 0 ]; then
582
584
  echo "No per-AC screenshots for ${REQ} (none captured by evidenceShot this run)"
585
+ # devaudit-installer#169 — track REQs with zero screenshots
586
+ # so the evidence-completeness gate below can fail the job
587
+ # if the REQ also has zero tagged tests in the Playwright
588
+ # report. Previously this was a silent continue — the release
589
+ # proceeded to UAT with no traceable evidence.
590
+ ZERO_Screenshot_REQS+=("$REQ")
583
591
  continue
584
592
  fi
585
593
  echo "Uploading ${#REQ_SHOTS[@]} screenshot(s) for ${REQ}"
@@ -626,6 +634,52 @@ jobs:
626
634
  fi
627
635
  shopt -u nullglob
628
636
 
637
+ # devaudit-installer#169 — Evidence-completeness gate.
638
+ # For each in-scope REQ that had zero screenshots, check whether
639
+ # the Playwright JSON report contains at least one test tagged
640
+ # with that REQ. If a REQ has zero screenshots AND zero tagged
641
+ # tests, the release has no traceable evidence and must not
642
+ # proceed to UAT. Previously this was a silent pass — the release
643
+ # arrived in UAT review with "SCREENSHOTS: none" and "TESTS: no
644
+ # tests in report tagged with this REQ" on the portal.
645
+ if [ "${#ZERO_Screenshot_REQS[@]}" -gt 0 ]; then
646
+ for REQ in "${ZERO_Screenshot_REQS[@]}"; do
647
+ TAGGED_COUNT=0
648
+ if [ -f ci-evidence/e2e-results.json ]; then
649
+ TAGGED_COUNT=$(python3 -c "
650
+ import json, sys
651
+ with open('ci-evidence/e2e-results.json') as f:
652
+ data = json.load(f)
653
+ def count(specs):
654
+ n = 0
655
+ for s in specs:
656
+ tags = []
657
+ for a in s.get('annotations', []):
658
+ tags.append(a.get('type','') + ':' + a.get('description',''))
659
+ for t in s.get('tags', []):
660
+ tags.append(t)
661
+ title = s.get('title', '')
662
+ if '${REQ}' in title or any('${REQ}' in t for t in tags):
663
+ n += 1
664
+ n += count(s.get('suites', []))
665
+ return n
666
+ print(count(data.get('suites', [])))
667
+ " 2>/dev/null || echo "0")
668
+ fi
669
+ if [ "$TAGGED_COUNT" -eq 0 ]; then
670
+ echo "::error::Evidence completeness gate: ${REQ} has zero tagged tests AND zero per-AC screenshots. The release cannot proceed to UAT with no traceable evidence. Ensure specs carry @requirement ${REQ} annotations and evidenceShot() calls (skill Phase 5½, issue #170)."
671
+ EVIDENCE_GAPS=$((EVIDENCE_GAPS + 1))
672
+ else
673
+ echo "${REQ} has zero screenshots but ${TAGGED_COUNT} tagged test(s) — evidence-completeness gate passed (screenshot gap is non-blocking)"
674
+ fi
675
+ done
676
+ fi
677
+
678
+ if [ "$EVIDENCE_GAPS" -gt 0 ]; then
679
+ echo "::error::${EVIDENCE_GAPS} in-scope REQ(s) have zero traceable evidence (no tagged tests, no screenshots) — release cannot pass UAT review"
680
+ exit 1
681
+ fi
682
+
629
683
  # NOTE: committed compliance docs (planning category: RTM/test-plan/
630
684
  # test-cases, release tickets, and per-requirement
631
685
  # compliance/evidence/REQ-*/ folders) are intentionally NOT uploaded
@@ -639,6 +639,13 @@ jobs:
639
639
  (cd e2e-artifacts && zip -qr playwright-report.zip playwright-report/) 2>/dev/null || true
640
640
  fi
641
641
  if [ -f e2e-artifacts/playwright-report.zip ]; then
642
+ # DevAudit-Installer#189 — the Playwright HTML report zip
643
+ # (~50–100MB) exceeds the portal's multipart body parser
644
+ # limit. The JSON results above are the canonical evidence;
645
+ # the HTML report is a human-facing convenience. Soft-fail
646
+ # so the job doesn't block on a portal-side size limit.
647
+ # Once the portal's presigned R2 URL flow is live (#189
648
+ # Option C), this can be reverted to a hard fail.
642
649
  if bash scripts/upload-evidence.sh \
643
650
  {{PROJECT_SLUG}} "$REQ" test_report \
644
651
  e2e-artifacts/playwright-report.zip \
@@ -647,8 +654,7 @@ jobs:
647
654
  then
648
655
  :
649
656
  else
650
- echo "::warning::playwright HTML report upload failed for ${REQ}"
651
- UPLOAD_FAILURES=$((UPLOAD_FAILURES + 1))
657
+ echo "::warning::playwright HTML report upload failed for ${REQ} (non-blocking — JSON results are canonical evidence)"
652
658
  fi
653
659
  fi
654
660
  done
@@ -43,22 +43,72 @@ jobs:
43
43
  needs: detect-req
44
44
  if: needs.detect-req.outputs.has_tests == 'true'
45
45
  runs-on: {{RUNNER}}
46
+
47
+ services:
48
+ {{DATABASE_SERVICE}}:
49
+ image: {{DATABASE_IMAGE}}
50
+ ports:
51
+ - {{DATABASE_PORT}}
52
+
46
53
  env:
54
+ {{DATABASE_ENV}}
55
+ {{APP_ENV}}
47
56
  DEVAUDIT_BASE_URL_VAR: ${{ vars.DEVAUDIT_BASE_URL }}
48
57
  DEVAUDIT_API_KEY: ${{ secrets.DEVAUDIT_API_KEY }}
58
+
49
59
  steps:
50
60
  - uses: actions/checkout@v6
51
61
  with:
52
62
  fetch-depth: 0
53
63
  - uses: actions/setup-node@v4
54
64
  with:
55
- node-version: '20'
56
- - run: npm ci
57
- - run: npx playwright install --with-deps
58
- - name: Run in-scope E2E
65
+ node-version: {{NODE_VERSION}}
66
+ - name: Install dependencies (skip if lockfile unchanged)
59
67
  run: |
60
- REQ_ID="${{ needs.detect-req.outputs.req_id }}"
61
- npx playwright test --grep "$REQ_ID"
68
+ LOCK_HASH=$(sha256sum package-lock.json | cut -d' ' -f1)
69
+ if [ -f node_modules/.lock-hash ] && [ "$(cat node_modules/.lock-hash)" = "$LOCK_HASH" ]; then
70
+ echo "node_modules up to date — skipping npm ci"
71
+ else
72
+ npm ci
73
+ echo "$LOCK_HASH" > node_modules/.lock-hash
74
+ fi
75
+ - name: Install Playwright (skip if already cached)
76
+ run: npx playwright install --with-deps chromium 2>/dev/null || npx playwright install chromium
77
+
78
+ {{DATABASE_URI_STEP}}
79
+ {{E2E_SETUP_STEP}}
80
+ - name: Kill stale dev server
81
+ run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true
82
+
83
+ {{E2E_DEV_SERVER_STEP}}
84
+
85
+ - name: Wait for dev server
86
+ run: npx wait-on http://localhost:3000 --timeout 120000
87
+
88
+ # Compute the set of e2e spec files added on this branch (relative
89
+ # to the merge base). The evidenceShot helper reads E2E_NEW_SPECS
90
+ # at capture time and tags each screenshot `origin=feature` when
91
+ # its spec appears in the list, else `origin=regression`.
92
+ - name: Compute new-spec list for E2E origin tagging
93
+ run: |
94
+ BASE_REF="${{ github.base_ref || 'develop' }}"
95
+ BASE_SHA=$(git merge-base "origin/${BASE_REF}" HEAD 2>/dev/null || git rev-parse HEAD)
96
+ NEW_SPECS=$(git diff --name-only --diff-filter=A "${BASE_SHA}...HEAD" \
97
+ -- 'tests/e2e/**/*.spec.ts' 'tests/e2e/**/*.spec.tsx' \
98
+ 'e2e/**/*.spec.ts' 'e2e/**/*.spec.tsx' 2>/dev/null || true)
99
+ {
100
+ echo "E2E_NEW_SPECS<<EOF"
101
+ echo "$NEW_SPECS"
102
+ echo "EOF"
103
+ } >> "$GITHUB_ENV"
104
+ if [ -n "$NEW_SPECS" ]; then
105
+ echo "New e2e specs on this branch (origin=feature for captures):"
106
+ echo "$NEW_SPECS" | sed 's/^/ /'
107
+ else
108
+ echo "No new e2e specs detected — all captures will tag origin=regression."
109
+ fi
110
+
111
+ {{E2E_FEATURE_TEST_STEP}}
62
112
  - name: Upload feature E2E evidence (stage 2, origin=feature)
63
113
  if: always()
64
114
  run: |