@metasession.co/devaudit-cli 0.3.8 → 0.3.10

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.
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env bash
2
+ # @requirement REQ-007 - CLI upload script for CI pipelines
3
+ # @requirement REQ-020 - Extended with release/environment/category flags
4
+ # @requirement REQ-XXX - #224: posts to META-COMPLY's API instead of Supabase
5
+ #
6
+ # Usage:
7
+ # ./scripts/upload-evidence.sh <project-slug> <requirement-id> <evidence-type> <file-or-directory>
8
+ #
9
+ # Optional flags:
10
+ # --git-sha <sha> Git commit SHA
11
+ # --ci-run-id <id> CI run identifier
12
+ # --branch <branch> Git branch name
13
+ # --release <version> Release version (e.g. v1.0.0). The route
14
+ # resolves it to a release UUID server-side.
15
+ # --create-release-if-missing Auto-create the release as 'draft' if absent
16
+ # --environment <env> Environment: uat or production
17
+ # --category <cat> Evidence category: ci_pipeline, local_dev,
18
+ # planning, test_report, security_scan,
19
+ # release_artifact
20
+ # --release-title <text> Human title for the release row (e.g. the
21
+ # release-ticket H1). Forwarded as
22
+ # `releaseTitle`; the portal no-clobbers
23
+ # existing non-null values.
24
+ # --release-summary <text> Reviewer-facing short description of what
25
+ # the release covers. Forwarded as
26
+ # `releaseSummary`; the portal no-clobbers
27
+ # existing non-null values.
28
+ # DevAudit-Installer#285.
29
+ # --change-type <type> Conventional-commit prefix (feat / fix /
30
+ # refactor / perf / chore / docs / ci /
31
+ # build / test / compliance / revert) for
32
+ # the release row. Unknown values are
33
+ # silently dropped server-side.
34
+ # --gate-status <status> `passed` / `failed` / `skipped`. Lets the
35
+ # portal distinguish a gate that ran-and-
36
+ # failed from one that never ran. Forwarded
37
+ # as `gateStatus`; unknown values are
38
+ # silently dropped server-side.
39
+ # DevAudit-Installer#96.
40
+ # --sdlc-stage <1-5> SDLC stage that produced this artefact:
41
+ # 1 plan, 2 implement/test, 3 compile-evidence,
42
+ # 4 submit-for-review, 5 deploy. Forwarded as
43
+ # `sdlcStage`; unknown to older portals (ignored
44
+ # server-side, no error).
45
+ # --test-cycle <id> Test cycle identifier (typically the CI run
46
+ # ID). Forwarded as `testCycleId`; lets the
47
+ # portal group evidence by test cycle per
48
+ # ISO/IEC/IEEE 29119-3. Optional — older
49
+ # portals ignore the field (no error).
50
+ #
51
+ # Required environment variables:
52
+ # DEVAUDIT_BASE_URL e.g. https://meta-comply-production.up.railway.app
53
+ # DEVAUDIT_API_KEY project-scoped API key (uploader role); format `mc_…`.
54
+ # Issue from: Project Settings → API Keys in
55
+ # META-COMPLY's web UI.
56
+ #
57
+ # Large-file uploads (>=25MB):
58
+ # Files above 25MB use a presigned R2 upload flow (3-step: request URL →
59
+ # PUT to R2 → notify portal) instead of multipart POST. This requires
60
+ # the Portal to be configured with R2 evidence storage
61
+ # (R2_EVIDENCE_BUCKET and related env vars). If R2 is not configured,
62
+ # the Portal returns a clear error which is surfaced to the operator.
63
+ # #298 — the Portal response body is included in the error output.
64
+ #
65
+ # Examples:
66
+ # ./scripts/upload-evidence.sh meta-ats REQ-001 screenshot \
67
+ # compliance/evidence/REQ-001/screenshots/
68
+ # ./scripts/upload-evidence.sh meta-ats _compliance-docs compliance_document \
69
+ # compliance/RTM.md --git-sha abc123
70
+ # ./scripts/upload-evidence.sh meta-ats REQ-001 e2e_result \
71
+ # playwright-report/ --release v1.0.0 --environment uat \
72
+ # --category ci_pipeline --create-release-if-missing
73
+
74
+ set -euo pipefail
75
+
76
+ # --- Parse arguments ---
77
+ if [ "$#" -lt 4 ]; then
78
+ echo "Usage: $0 <project-slug> <requirement-id> <evidence-type> <file-or-directory> [flags…]"
79
+ exit 1
80
+ fi
81
+
82
+ PROJECT_SLUG="$1"
83
+ REQUIREMENT_ID="$2"
84
+ EVIDENCE_TYPE="$3"
85
+ FILE_PATH="$4"
86
+ shift 4
87
+
88
+ GIT_SHA=""
89
+ CI_RUN_ID=""
90
+ BRANCH=""
91
+ RELEASE_VERSION=""
92
+ CREATE_RELEASE_IF_MISSING=false
93
+ ENVIRONMENT=""
94
+ EVIDENCE_CATEGORY=""
95
+ RELEASE_TITLE=""
96
+ RELEASE_SUMMARY=""
97
+ CHANGE_TYPE=""
98
+ GATE_STATUS=""
99
+ SDLC_STAGE=""
100
+ TEST_CYCLE=""
101
+ # Repeatable `--meta-key key=value` accumulator. Each pair gets merged
102
+ # into the metadata JSON sent to the portal. Used by the screenshot
103
+ # upload loop to pass `origin=feature|regression` from the per-PNG
104
+ # sidecar JSON written by the evidenceShot helper.
105
+ META_KEYS=()
106
+
107
+ while [ "$#" -gt 0 ]; do
108
+ case "$1" in
109
+ --git-sha) GIT_SHA="$2"; shift 2 ;;
110
+ --ci-run-id) CI_RUN_ID="$2"; shift 2 ;;
111
+ --branch) BRANCH="$2"; shift 2 ;;
112
+ --release) RELEASE_VERSION="$2"; shift 2 ;;
113
+ --create-release-if-missing) CREATE_RELEASE_IF_MISSING=true; shift ;;
114
+ --environment) ENVIRONMENT="$2"; shift 2 ;;
115
+ --category) EVIDENCE_CATEGORY="$2"; shift 2 ;;
116
+ # Descriptive title + conventional-commit change type passed through to
117
+ # the portal's findOrCreateRelease no-clobber backfill. Both optional;
118
+ # unknown change-type values are dropped server-side, not 400'd.
119
+ --release-title) RELEASE_TITLE="$2"; shift 2 ;;
120
+ --release-summary) RELEASE_SUMMARY="$2"; shift 2 ;;
121
+ --change-type) CHANGE_TYPE="$2"; shift 2 ;;
122
+ # passed/failed/skipped — surfaces failed gates on the portal so
123
+ # ran-and-failed != never-ran. Unknown values dropped server-side.
124
+ # DevAudit-Installer#96.
125
+ --gate-status) GATE_STATUS="$2"; shift 2 ;;
126
+ --sdlc-stage) SDLC_STAGE="$2"; shift 2 ;;
127
+ --test-cycle) TEST_CYCLE="$2"; shift 2 ;;
128
+ # --meta-key key=value (repeatable). Merged into the metadata JSON
129
+ # before posting. Validates the `key=value` shape; rejects bare
130
+ # keys without `=`.
131
+ --meta-key)
132
+ if [[ "$2" != *=* ]]; then
133
+ echo "Error: --meta-key requires key=value (got: $2)"
134
+ exit 1
135
+ fi
136
+ META_KEYS+=("$2")
137
+ shift 2
138
+ ;;
139
+ *) echo "Unknown option: $1"; exit 1 ;;
140
+ esac
141
+ done
142
+
143
+ if [ -n "$ENVIRONMENT" ] && [ -z "$RELEASE_VERSION" ]; then
144
+ echo "Error: --environment requires --release (evidence without a release is orphaned)"
145
+ exit 1
146
+ fi
147
+ if [ -n "$RELEASE_VERSION" ] && [ -z "$EVIDENCE_CATEGORY" ]; then
148
+ echo "Error: --category is required when --release is specified (gate validation)"
149
+ exit 1
150
+ fi
151
+ if [ -n "$SDLC_STAGE" ] && ! [[ "$SDLC_STAGE" =~ ^[1-5]$ ]]; then
152
+ echo "Error: --sdlc-stage must be an integer 1-5 (got: $SDLC_STAGE)"
153
+ exit 1
154
+ fi
155
+
156
+ if [ -z "${DEVAUDIT_BASE_URL:-}" ]; then
157
+ echo "Error: DEVAUDIT_BASE_URL environment variable is required"
158
+ exit 1
159
+ fi
160
+ if [ -z "${DEVAUDIT_API_KEY:-}" ]; then
161
+ echo "Error: DEVAUDIT_API_KEY environment variable is required (issue from"
162
+ echo " Project Settings → API Keys in META-COMPLY)"
163
+ exit 1
164
+ fi
165
+
166
+ # Strip any trailing slash so we don't double-up later.
167
+ DEVAUDIT_BASE_URL="${DEVAUDIT_BASE_URL%/}"
168
+
169
+ # --- Base-URL drift check (devaudit-installer#143) ---
170
+ # When the portal moves host (e.g. devaudit.metasession.co → devaudit.ai)
171
+ # Cloudflare / the origin replies 301/302 with a Location header pointing
172
+ # at the new host. Every consumer's CI that still uses the old base URL
173
+ # fails every upload until DEVAUDIT_BASE_URL is rotated. We don't want
174
+ # the failure mode to be a silent "evidence didn't upload" — surface the
175
+ # drift loudly at the top of the run so the operator knows to rotate the
176
+ # secret. (We still upload via `curl -L` so the run itself succeeds; the
177
+ # warning is the nudge to fix the secret, not a hard stop.)
178
+ probe_base_url_drift() {
179
+ # `${var:-}` so `set -u` doesn't trip if curl isn't installed or the
180
+ # network is offline. Probe with -I (HEAD); fall back to GET if HEAD
181
+ # is rejected by the upstream proxy. 5s connect + 10s overall is
182
+ # plenty for a redirect-only probe — we never read a body.
183
+ local probe_url="${DEVAUDIT_BASE_URL}/api/health"
184
+ local resp
185
+ resp=$(curl -sI -o /dev/null --max-time 10 --connect-timeout 5 \
186
+ -w "%{http_code} %{redirect_url}" "$probe_url" 2>/dev/null || true)
187
+ local code="${resp%% *}"
188
+ local redirect_url="${resp#* }"
189
+ if [[ "$code" =~ ^30[1278]$ ]] && [ -n "$redirect_url" ] && [ "$redirect_url" != " " ]; then
190
+ local old_host new_host
191
+ old_host=$(printf '%s' "$DEVAUDIT_BASE_URL" | sed -E 's|^https?://([^/]+).*|\1|')
192
+ new_host=$(printf '%s' "$redirect_url" | sed -E 's|^https?://([^/]+).*|\1|')
193
+ if [ -n "$new_host" ] && [ "$old_host" != "$new_host" ]; then
194
+ echo "WARNING: DEVAUDIT_BASE_URL host '${old_host}' redirects to '${new_host}'."
195
+ echo " Rotate the DEVAUDIT_BASE_URL secret in your CI environment to"
196
+ echo " the new host to avoid silent breakage. (Uploads will still"
197
+ echo " succeed this run — curl follows the redirect — but the"
198
+ echo " underlying secret should be updated.)"
199
+ echo " Ref: https://github.com/metasession-dev/DevAudit-Installer/issues/143"
200
+ fi
201
+ fi
202
+ }
203
+ probe_base_url_drift
204
+
205
+ # --- Build metadata JSON ---
206
+ # Assemble entries first; only emit `{ ... }` if at least one field is
207
+ # set. Each entry is a `"key":"value"` JSON pair with the value
208
+ # json-escaped (quotes + backslashes).
209
+ json_escape() {
210
+ printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
211
+ }
212
+ META_ENTRIES=()
213
+ [ -n "$GIT_SHA" ] && META_ENTRIES+=("\"gitSha\":\"$(json_escape "$GIT_SHA")\"")
214
+ [ -n "$CI_RUN_ID" ] && META_ENTRIES+=("\"ciRunId\":\"$(json_escape "$CI_RUN_ID")\"")
215
+ [ -n "$BRANCH" ] && META_ENTRIES+=("\"branch\":\"$(json_escape "$BRANCH")\"")
216
+ for KV in "${META_KEYS[@]}"; do
217
+ KEY="${KV%%=*}"
218
+ VAL="${KV#*=}"
219
+ META_ENTRIES+=("\"$(json_escape "$KEY")\":\"$(json_escape "$VAL")\"")
220
+ done
221
+ if [ "${#META_ENTRIES[@]}" -gt 0 ]; then
222
+ IFS=','
223
+ METADATA="{${META_ENTRIES[*]}}"
224
+ unset IFS
225
+ else
226
+ METADATA="{}"
227
+ fi
228
+
229
+ # --- Collect files ---
230
+ FILES=()
231
+ if [ -d "$FILE_PATH" ]; then
232
+ while IFS= read -r -d '' f; do
233
+ FILES+=("$f")
234
+ done < <(find "$FILE_PATH" -type f -print0)
235
+ else
236
+ FILES=("$FILE_PATH")
237
+ fi
238
+ if [ ${#FILES[@]} -eq 0 ]; then
239
+ echo "Error: No files found at $FILE_PATH"
240
+ exit 1
241
+ fi
242
+
243
+ # --- Upload each file via /api/evidence/upload ---
244
+ #
245
+ # Retry-on-429 / 5xx: consumer CI workflows fire 8-12 sequential uploads per
246
+ # requirement (test-scope, test-plan, implementation-plan, security-summary,
247
+ # test-execution-summary, ai-prompts, ai-use-note, uat-checklist, gates/*.json).
248
+ # That cadence trips DevAudit's per-IP rate limiter, marking otherwise-green
249
+ # CI runs as failed. Retry transiently with exponential backoff (1s → 16s),
250
+ # honouring Retry-After if the portal supplies it. Auth/validation errors
251
+ # (4xx other than 429) still fail fast — they won't fix themselves.
252
+ #
253
+ # Issue: devaudit#263.
254
+ SUCCEEDED=0
255
+ FAILED=0
256
+ # devaudit#133 — central stub guard. Any file still carrying the
257
+ # DevAudit starter banner ("STARTER TEMPLATE — REPLACE BEFORE
258
+ # COMMITTING" / "...BEFORE GOING TO PRODUCTION" — both phrasings)
259
+ # is skipped before the upload attempt so unedited placeholders
260
+ # can't flip a clause to COVERED off a stub. The check is binary-
261
+ # safe (-a) so it doesn't choke on PNGs or other non-text files.
262
+ SKIPPED=0
263
+ TOTAL_SIZE=0
264
+ UPLOAD_URL="${DEVAUDIT_BASE_URL}/api/evidence/upload"
265
+ MAX_ATTEMPTS=${UPLOAD_MAX_ATTEMPTS:-5}
266
+ INITIAL_BACKOFF_SECONDS=${UPLOAD_INITIAL_BACKOFF_SECONDS:-1}
267
+ UPLOAD_CONNECT_TIMEOUT_SECONDS=${UPLOAD_CONNECT_TIMEOUT_SECONDS:-10}
268
+ UPLOAD_MAX_TIME_SECONDS=${UPLOAD_MAX_TIME_SECONDS:-120}
269
+ # DevAudit-Installer#189 — files above this size use the presigned R2 URL
270
+ # upload flow (3-step: request URL → PUT to R2 → notify portal) instead of
271
+ # the multipart POST. Avoids the portal's FormData body parser limit.
272
+ # 25MB = 26214400 bytes.
273
+ PRESIGNED_THRESHOLD_BYTES=${PRESIGNED_THRESHOLD:-26214400}
274
+ PRESIGNED_MAX_ATTEMPTS=${PRESIGNED_MAX_ATTEMPTS:-3}
275
+ PRESIGNED_UPLOAD_MAX_TIME_SECONDS=${PRESIGNED_UPLOAD_MAX_TIME_SECONDS:-300}
276
+
277
+ is_unedited_starter_stub() {
278
+ # Match BOTH banner phrasings the SDLC has shipped (v0.1.36 changed
279
+ # the wording from "...GOING TO PRODUCTION" to "...COMMITTING").
280
+ # -a forces binary→text so we don't error on PNGs/PDFs; the regex
281
+ # won't match either of those formats by accident.
282
+ grep -aqE 'STARTER TEMPLATE.+REPLACE BEFORE' "$1"
283
+ }
284
+
285
+ # DevAudit-Installer#189 — Presigned R2 URL upload for large files (>25MB).
286
+ # 3-step flow: (1) request presigned URL from portal, (2) PUT file to R2,
287
+ # (3) notify portal that upload is complete. Each step retries on 429/5xx
288
+ # and connection errors. Returns 0 on success, 1 on failure.
289
+ upload_presigned() {
290
+ local file="$1"
291
+ local filename
292
+ filename=$(basename "$file")
293
+ local file_size
294
+ file_size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
295
+
296
+ # Derive a MIME type from the extension (best-effort).
297
+ local mime_type="application/octet-stream"
298
+ case "$filename" in
299
+ *.zip) mime_type="application/zip" ;;
300
+ *.json) mime_type="application/json" ;;
301
+ *.png) mime_type="image/png" ;;
302
+ *.pdf) mime_type="application/pdf" ;;
303
+ *.md) mime_type="text/markdown" ;;
304
+ *.html) mime_type="text/html" ;;
305
+ esac
306
+
307
+ local presign_url="${DEVAUDIT_BASE_URL}/api/evidence/upload-url"
308
+ local complete_url="${DEVAUDIT_BASE_URL}/api/evidence/upload-complete"
309
+
310
+ # --- Step 1: Request presigned upload URL ---
311
+ local attempt backoff http_code curl_exit resp_body upload_url evidence_id
312
+ attempt=1
313
+ backoff=$INITIAL_BACKOFF_SECONDS
314
+ upload_url=""
315
+ evidence_id=""
316
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
317
+ resp_body=$(mktemp)
318
+ http_code=$(curl -s -o "$resp_body" -w "%{http_code}" \
319
+ -X POST -L --max-redirs 3 \
320
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
321
+ --max-time "$UPLOAD_MAX_TIME_SECONDS" \
322
+ "$presign_url" \
323
+ -H "Authorization: Bearer ${DEVAUDIT_API_KEY}" \
324
+ -H "Content-Type: application/json" \
325
+ -d "{
326
+ \"projectSlug\": \"${PROJECT_SLUG}\",
327
+ \"requirementId\": \"${REQUIREMENT_ID}\",
328
+ \"evidenceType\": \"${EVIDENCE_TYPE}\",
329
+ \"fileName\": \"${filename}\",
330
+ \"fileSizeBytes\": ${file_size},
331
+ \"mimeType\": \"${mime_type}\",
332
+ \"metadata\": ${METADATA},
333
+ \"releaseVersion\": \"${RELEASE_VERSION}\",
334
+ \"createReleaseIfMissing\": \"${CREATE_RELEASE_IF_MISSING}\",
335
+ \"releaseBranch\": \"${BRANCH}\",
336
+ \"environment\": \"${ENVIRONMENT}\",
337
+ \"evidenceCategory\": \"${EVIDENCE_CATEGORY}\",
338
+ \"releaseTitle\": \"${RELEASE_TITLE}\",
339
+ \"releaseSummary\": \"${RELEASE_SUMMARY}\",
340
+ \"sdlcStage\": \"${SDLC_STAGE}\",
341
+ \"testCycleId\": \"${TEST_CYCLE}\"
342
+ }") || curl_exit=$?
343
+ curl_exit=${curl_exit:-0}
344
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
345
+ upload_url=$(jq -r '.uploadUrl // empty' "$resp_body" 2>/dev/null || true)
346
+ evidence_id=$(jq -r '.evidenceId // empty' "$resp_body" 2>/dev/null || true)
347
+ rm -f "$resp_body"
348
+ if [ -n "$upload_url" ] && [ -n "$evidence_id" ] && [ "$upload_url" != "null" ] && [ "$evidence_id" != "null" ]; then
349
+ break
350
+ fi
351
+ # Portal responded 2xx but didn't return presigned URL fields —
352
+ # presigned URL flow not configured. Fall back to multipart.
353
+ echo -n "(portal did not return presigned URL, falling back to multipart) "
354
+ return 255
355
+ fi
356
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
357
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
358
+ rm -f "$resp_body"
359
+ echo -n "(step 1: HTTP ${http_code}, retry in ${backoff}s) "
360
+ sleep "$backoff"
361
+ attempt=$((attempt + 1))
362
+ backoff=$((backoff * 2))
363
+ continue
364
+ fi
365
+ fi
366
+ # Non-retriable error (4xx other than 429) or exhausted retries.
367
+ # #298 — Surface the Portal response body so operators see WHY it
368
+ # failed (e.g. "Presigned uploads require R2 storage configuration")
369
+ # instead of just "HTTP 501". Include up to 500 chars of the body.
370
+ local err_excerpt=""
371
+ if [ -s "$resp_body" ]; then
372
+ err_excerpt=$(head -c 500 "$resp_body")
373
+ fi
374
+ rm -f "$resp_body"
375
+ echo -n "(step 1 failed: HTTP ${http_code}"
376
+ if [ -n "$err_excerpt" ]; then
377
+ echo -n " — ${err_excerpt}"
378
+ fi
379
+ echo ") "
380
+ return 1
381
+ done
382
+
383
+ if [ -z "$upload_url" ] || [ -z "$evidence_id" ]; then
384
+ echo -n "(step 1: no presigned URL after ${attempt} attempts) "
385
+ return 1
386
+ fi
387
+
388
+ # --- Step 2: Upload directly to R2 via presigned URL ---
389
+ attempt=1
390
+ backoff=$INITIAL_BACKOFF_SECONDS
391
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
392
+ http_code=$(curl -s -o /dev/null -w "%{http_code}" \
393
+ -X PUT \
394
+ -H "Content-Type: ${mime_type}" \
395
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
396
+ --max-time "$PRESIGNED_UPLOAD_MAX_TIME_SECONDS" \
397
+ --data-binary @"$file" \
398
+ "$upload_url") || curl_exit=$?
399
+ curl_exit=${curl_exit:-0}
400
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
401
+ break
402
+ fi
403
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
404
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
405
+ echo -n "(step 2: HTTP ${http_code}, retry in ${backoff}s) "
406
+ sleep "$backoff"
407
+ attempt=$((attempt + 1))
408
+ backoff=$((backoff * 2))
409
+ continue
410
+ fi
411
+ fi
412
+ echo -n "(step 2 failed: HTTP ${http_code}) "
413
+ return 1
414
+ done
415
+
416
+ # --- Step 3: Notify portal that upload is complete ---
417
+ attempt=1
418
+ backoff=$INITIAL_BACKOFF_SECONDS
419
+ while [ "$attempt" -le "$PRESIGNED_MAX_ATTEMPTS" ]; do
420
+ http_code=$(curl -s -o /dev/null -w "%{http_code}" \
421
+ -X POST -L --max-redirs 3 \
422
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS" \
423
+ --max-time "$UPLOAD_MAX_TIME_SECONDS" \
424
+ "$complete_url" \
425
+ -H "Authorization: Bearer ${DEVAUDIT_API_KEY}" \
426
+ -H "Content-Type: application/json" \
427
+ -d "{\"evidenceId\": \"${evidence_id}\"}") || curl_exit=$?
428
+ curl_exit=${curl_exit:-0}
429
+ if [ "$curl_exit" -eq 0 ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
430
+ return 0
431
+ fi
432
+ if [ "$curl_exit" -ne 0 ] || [ "$http_code" = "429" ] || { [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; }; then
433
+ if [ "$attempt" -lt "$PRESIGNED_MAX_ATTEMPTS" ]; then
434
+ echo -n "(step 3: HTTP ${http_code}, retry in ${backoff}s) "
435
+ sleep "$backoff"
436
+ attempt=$((attempt + 1))
437
+ backoff=$((backoff * 2))
438
+ continue
439
+ fi
440
+ fi
441
+ echo -n "(step 3 failed: HTTP ${http_code}) "
442
+ return 1
443
+ done
444
+ return 1
445
+ }
446
+
447
+ for FILE in "${FILES[@]}"; do
448
+ FILENAME=$(basename "$FILE")
449
+ if is_unedited_starter_stub "$FILE"; then
450
+ echo "SKIPPED ${FILENAME} — unedited starter stub (replace the STARTER TEMPLATE banner to upload)"
451
+ SKIPPED=$((SKIPPED + 1))
452
+ continue
453
+ fi
454
+ FILE_SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE")
455
+ echo -n "Uploading ${FILENAME}... "
456
+
457
+ # DevAudit-Installer#189 — large files use the presigned R2 URL flow
458
+ # to bypass the portal's multipart body parser limit. If the portal
459
+ # doesn't support presigned URLs (returns 255), fall back to multipart.
460
+ if [ "$FILE_SIZE" -ge "$PRESIGNED_THRESHOLD_BYTES" ]; then
461
+ PRESIGNED_RESULT=0
462
+ upload_presigned "$FILE" || PRESIGNED_RESULT=$?
463
+ if [ "$PRESIGNED_RESULT" -eq 0 ]; then
464
+ echo "OK (${FILE_SIZE} bytes, presigned R2 upload)"
465
+ SUCCEEDED=$((SUCCEEDED + 1))
466
+ TOTAL_SIZE=$((TOTAL_SIZE + FILE_SIZE))
467
+ continue
468
+ elif [ "$PRESIGNED_RESULT" -ne 255 ]; then
469
+ echo "FAILED (presigned upload after ${PRESIGNED_MAX_ATTEMPTS} attempts)"
470
+ FAILED=$((FAILED + 1))
471
+ continue
472
+ fi
473
+ # Result 255 — portal doesn't support presigned URLs, fall through
474
+ # to the existing multipart flow.
475
+ fi
476
+
477
+ # `-L` follows 3xx redirects (devaudit-installer#143). The portal host
478
+ # has moved before (devaudit.metasession.co → devaudit.ai); without -L
479
+ # every consumer's CI silently fails on a stale base URL. `--max-redirs 3`
480
+ # bounds the follow so a misconfigured redirect loop can't hang CI.
481
+ CURL_ARGS=(
482
+ -X POST -L --max-redirs 3
483
+ --connect-timeout "$UPLOAD_CONNECT_TIMEOUT_SECONDS"
484
+ --max-time "$UPLOAD_MAX_TIME_SECONDS"
485
+ "$UPLOAD_URL"
486
+ -H "Authorization: Bearer ${DEVAUDIT_API_KEY}"
487
+ -F "file=@${FILE}"
488
+ -F "projectSlug=${PROJECT_SLUG}"
489
+ -F "requirementId=${REQUIREMENT_ID}"
490
+ -F "evidenceType=${EVIDENCE_TYPE}"
491
+ -F "metadata=${METADATA}"
492
+ )
493
+ [ -n "$RELEASE_VERSION" ] && CURL_ARGS+=(-F "releaseVersion=${RELEASE_VERSION}")
494
+ if [ "$CREATE_RELEASE_IF_MISSING" = true ]; then
495
+ CURL_ARGS+=(-F "createReleaseIfMissing=true")
496
+ fi
497
+ [ -n "$BRANCH" ] && CURL_ARGS+=(-F "releaseBranch=${BRANCH}")
498
+ [ -n "$ENVIRONMENT" ] && CURL_ARGS+=(-F "environment=${ENVIRONMENT}")
499
+ [ -n "$EVIDENCE_CATEGORY" ] && CURL_ARGS+=(-F "evidenceCategory=${EVIDENCE_CATEGORY}")
500
+ [ -n "$RELEASE_TITLE" ] && CURL_ARGS+=(-F "releaseTitle=${RELEASE_TITLE}")
501
+ [ -n "$RELEASE_SUMMARY" ] && CURL_ARGS+=(-F "releaseSummary=${RELEASE_SUMMARY}")
502
+ [ -n "$CHANGE_TYPE" ] && CURL_ARGS+=(-F "changeType=${CHANGE_TYPE}")
503
+ [ -n "$GATE_STATUS" ] && CURL_ARGS+=(-F "gateStatus=${GATE_STATUS}")
504
+ [ -n "$SDLC_STAGE" ] && CURL_ARGS+=(-F "sdlcStage=${SDLC_STAGE}")
505
+ [ -n "$TEST_CYCLE" ] && CURL_ARGS+=(-F "testCycleId=${TEST_CYCLE}")
506
+
507
+ ATTEMPT=1
508
+ BACKOFF=$INITIAL_BACKOFF_SECONDS
509
+ HTTP_CODE=0
510
+ RESP_BODY_FILE=""
511
+ RESP_HEADERS_FILE=""
512
+ LAST_CURL_ERROR=""
513
+ while [ "$ATTEMPT" -le "$MAX_ATTEMPTS" ]; do
514
+ [ -n "$RESP_BODY_FILE" ] && rm -f "$RESP_BODY_FILE"
515
+ RESP_BODY_FILE=$(mktemp)
516
+ RESP_HEADERS_FILE=$(mktemp)
517
+ CURL_EXIT=0
518
+ HTTP_CODE=$(curl -s -o "$RESP_BODY_FILE" -D "$RESP_HEADERS_FILE" -w "%{http_code}" "${CURL_ARGS[@]}") || CURL_EXIT=$?
519
+ if [ "$CURL_EXIT" -ne 0 ]; then
520
+ LAST_CURL_ERROR="curl exit ${CURL_EXIT}"
521
+ if [ "$CURL_EXIT" -eq 28 ]; then
522
+ LAST_CURL_ERROR="${LAST_CURL_ERROR} (timed out after ${UPLOAD_MAX_TIME_SECONDS}s)"
523
+ fi
524
+ if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
525
+ WAIT_SECONDS=$BACKOFF
526
+ echo -n "(${LAST_CURL_ERROR}, retry in ${WAIT_SECONDS}s) "
527
+ rm -f "$RESP_HEADERS_FILE"
528
+ sleep "$WAIT_SECONDS"
529
+ ATTEMPT=$((ATTEMPT + 1))
530
+ BACKOFF=$((BACKOFF * 2))
531
+ continue
532
+ fi
533
+ rm -f "$RESP_HEADERS_FILE"
534
+ break
535
+ fi
536
+ if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
537
+ rm -f "$RESP_HEADERS_FILE"
538
+ break
539
+ fi
540
+ # Decide whether the failure is retriable (429 or 5xx).
541
+ if [ "$HTTP_CODE" = "429" ] || { [ "$HTTP_CODE" -ge 500 ] && [ "$HTTP_CODE" -lt 600 ]; }; then
542
+ if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
543
+ # Honour Retry-After (seconds) if the portal supplied it; otherwise back off.
544
+ WAIT_SECONDS=$BACKOFF
545
+ RETRY_AFTER=$(grep -i '^retry-after:' "$RESP_HEADERS_FILE" 2>/dev/null | head -1 | sed 's/^[Rr]etry-[Aa]fter:[[:space:]]*//' | tr -d '\r')
546
+ if [[ "$RETRY_AFTER" =~ ^[0-9]+$ ]] && [ "$RETRY_AFTER" -gt 0 ]; then
547
+ WAIT_SECONDS=$RETRY_AFTER
548
+ fi
549
+ echo -n "(HTTP ${HTTP_CODE}, retry in ${WAIT_SECONDS}s) "
550
+ rm -f "$RESP_HEADERS_FILE"
551
+ sleep "$WAIT_SECONDS"
552
+ ATTEMPT=$((ATTEMPT + 1))
553
+ BACKOFF=$((BACKOFF * 2))
554
+ continue
555
+ fi
556
+ fi
557
+ rm -f "$RESP_HEADERS_FILE"
558
+ break
559
+ done
560
+
561
+ if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
562
+ rm -f "$RESP_BODY_FILE"
563
+ if [ "$ATTEMPT" -gt 1 ]; then
564
+ echo "OK (${FILE_SIZE} bytes, attempt ${ATTEMPT}/${MAX_ATTEMPTS})"
565
+ else
566
+ echo "OK (${FILE_SIZE} bytes)"
567
+ fi
568
+ SUCCEEDED=$((SUCCEEDED + 1))
569
+ TOTAL_SIZE=$((TOTAL_SIZE + FILE_SIZE))
570
+ else
571
+ if [ -n "$LAST_CURL_ERROR" ]; then
572
+ echo "FAILED (${LAST_CURL_ERROR} after ${ATTEMPT} attempt(s))"
573
+ else
574
+ echo "FAILED (HTTP ${HTTP_CODE} after ${ATTEMPT} attempt(s))"
575
+ fi
576
+ if [ -s "$RESP_BODY_FILE" ]; then
577
+ echo " Response: $(head -c 500 "$RESP_BODY_FILE")"
578
+ fi
579
+ rm -f "$RESP_BODY_FILE"
580
+ FAILED=$((FAILED + 1))
581
+ fi
582
+ done
583
+
584
+ # --- Summary ---
585
+ echo ""
586
+ echo "=== Upload Summary ==="
587
+ echo "Files: ${SUCCEEDED} succeeded, ${FAILED} failed, ${SKIPPED} skipped (${#FILES[@]} total)"
588
+ echo "Total size: $((TOTAL_SIZE / 1024)) KB"
589
+ # Skipped stubs are intentional (devaudit#133) — they don't fail the
590
+ # run. Only true upload failures bump the exit code.
591
+ if [ "$FAILED" -gt 0 ]; then
592
+ exit 1
593
+ fi
@@ -37,6 +37,23 @@ fi
37
37
  TOTAL=0
38
38
  FAILED=0
39
39
 
40
+ ACTIVE_RELEASE_REQS=""
41
+ if [ -d compliance/pending-releases ]; then
42
+ ACTIVE_RELEASE_REQS=$(find compliance/pending-releases -maxdepth 1 -type f \
43
+ -name 'RELEASE-TICKET-REQ-*.md' -print 2>/dev/null \
44
+ | sed -E 's|.*/RELEASE-TICKET-(REQ-[0-9]+)\.md$|\1|' | sort -u || true)
45
+ fi
46
+ if [ -z "$ACTIVE_RELEASE_REQS" ] && [ -f compliance/RTM.md ]; then
47
+ ACTIVE_RELEASE_REQS=$(sed 's/\\|/ /g' compliance/RTM.md 2>/dev/null \
48
+ | grep -E '\|[[:space:]]+IN PROGRESS|\|[[:space:]]+TESTED - PENDING SIGN-OFF' \
49
+ | grep -oE '^\|[[:space:]]*REQ-[0-9]+' \
50
+ | grep -oE 'REQ-[0-9]+' | sort -u || true)
51
+ fi
52
+ ACTIVE_RELEASE_REQ_COUNT=0
53
+ if [ -n "$ACTIVE_RELEASE_REQS" ]; then
54
+ ACTIVE_RELEASE_REQ_COUNT=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | grep -c . || true)
55
+ fi
56
+
40
57
  while IFS= read -r sha; do
41
58
  TOTAL=$((TOTAL + 1))
42
59
  SUBJECT=$(git log -1 --format='%s' "$sha")
@@ -66,13 +83,21 @@ while IFS= read -r sha; do
66
83
  feat|fix|refactor|perf)
67
84
  if ! echo "$SUBJECT" | grep -qP '\[REQ-\d{3,}\]' \
68
85
  && ! echo "$BODY" | grep -qiP 'Ref:\s*REQ-\d{3,}'; then
69
- echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
70
- echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
71
- echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
72
- echo " from the originating issue in Phase 1)."
73
- FAILED=$((FAILED + 1))
74
- EXIT_CODE=1
75
- continue
86
+ if [ "$ACTIVE_RELEASE_REQ_COUNT" -eq 1 ]; then
87
+ ACTIVE_REQ=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | head -1)
88
+ echo "WARNING [$SHORT]: '$TYPE' cites no requirement, but the branch has one active tracked release (${ACTIVE_REQ})."
89
+ echo " Treating the release ticket / RTM context as authoritative for this already-merged history."
90
+ echo " Add a follow-up compliance note if this was not an intentional recovery path."
91
+ WARN_COUNT=$((WARN_COUNT + 1))
92
+ else
93
+ echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
94
+ echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
95
+ echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
96
+ echo " from the originating issue in Phase 1)."
97
+ FAILED=$((FAILED + 1))
98
+ EXIT_CODE=1
99
+ continue
100
+ fi
76
101
  fi
77
102
 
78
103
  # RTM provenance check (devaudit-installer#226): verify the REQ-XXX