@metasession.co/devaudit-cli 0.3.26 → 0.3.28

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.3.26",
3
+ "version": "0.3.28",
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.3.26",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.28",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -34,6 +34,10 @@
34
34
 
35
35
  set -euo pipefail
36
36
 
37
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
+ # shellcheck source=markdown-table.sh
39
+ source "$SCRIPT_DIR/markdown-table.sh"
40
+
37
41
  REQ_ID="${1:-}"
38
42
  RELEASE_PR=""
39
43
  shift || true
@@ -89,21 +93,14 @@ find_release_ticket_file() {
89
93
  update_rtm_status() {
90
94
  local req_id="$1"
91
95
  local target_status="$2"
92
- local tmp_file
96
+ local tmp_file status_col
93
97
  tmp_file="$(mktemp)"
94
- if [ -f "$RTM" ] && grep -qE "^\| ${req_id} " "$RTM"; then
95
- awk -v req="$req_id" -v target_status="$target_status" '
96
- BEGIN { FS="|"; OFS="|"; statuscol=0 }
98
+ status_col="$(markdown_table_column_index_for_row \
99
+ "$RTM" "$req_id" Status REQ-ID/ID Status 2>/dev/null || true)"
100
+ if [ -n "$status_col" ]; then
101
+ awk -v req="$req_id" -v target_status="$target_status" -v statuscol="$status_col" '
102
+ BEGIN { FS="|"; OFS="|" }
97
103
  /\\\|/ { gsub(/\\\|/, "\001", $0) }
98
- {
99
- cand=0; idseen=0
100
- for (i=1; i<=NF; i++) {
101
- c=$i; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
102
- if (c=="Status") cand=i
103
- if (c=="ID" || c=="REQ-ID" || c=="REQ ID" || c ~ /^Requirement/) idseen=1
104
- }
105
- if (cand>0 && idseen) statuscol=cand
106
- }
107
104
  $0 ~ ("^\\| " req " ") && statuscol>0 {
108
105
  cell=$statuscol
109
106
  note=""
@@ -39,6 +39,7 @@ done
39
39
  python3 - "$AUDIT_PATH" "$LOCK_PATH" "$EXCEPTIONS_PATH" "$OUTPUT_PATH" <<'PY'
40
40
  import json
41
41
  import os
42
+ import re
42
43
  import sys
43
44
  from datetime import date
44
45
 
@@ -144,8 +145,11 @@ for index, item in enumerate(exceptions, start=1):
144
145
  fail(f"exception {index} approvedAt cannot be in the future")
145
146
  if expires_at <= today:
146
147
  fail(f"exception {index} is expired on {item['expiresAt']}")
147
- if not item["remediationIssue"].startswith("https://github.com/"):
148
- fail(f"exception {index} remediationIssue must be a GitHub issue URL")
148
+ if re.fullmatch(r"https://github\.com/[^/]+/[^/]+/issues/[1-9][0-9]*", item["remediationIssue"]) is None:
149
+ fail(f"exception {index} remediationIssue must be an exact GitHub issue URL")
150
+ controls = item.get("compensatingControls")
151
+ if controls is not None and (not isinstance(controls, str) or not controls.strip()):
152
+ fail(f"exception {index} compensatingControls must be a non-empty string when present")
149
153
  key = (item["advisoryId"], item["dependencyPath"])
150
154
  if key in seen_exception_keys:
151
155
  fail(f"duplicate exception for {item['advisoryId']} at {item['dependencyPath']}")
@@ -217,10 +221,13 @@ for finding_name, finding in audit["vulnerabilities"].items():
217
221
  file=sys.stderr,
218
222
  )
219
223
  else:
220
- accepted.append({**context, "acceptance": {
224
+ acceptance = {
221
225
  field: match[field]
222
226
  for field in ("approvedAt", "expiresAt", "approvedBy", "reason", "remediationIssue")
223
- }})
227
+ }
228
+ if "compensatingControls" in match:
229
+ acceptance["compensatingControls"] = match["compensatingControls"]
230
+ accepted.append({**context, "acceptance": acceptance})
224
231
  print(
225
232
  f"Accepted temporary risk: {identifier} ({package}@{version}) "
226
233
  f"until {match['expiresAt']} by {match['approvedBy']}; "
@@ -7,6 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
7
  HELPER="$SCRIPT_DIR/evaluate-npm-audit.sh"
8
8
  WORK="$(mktemp -d)"
9
9
  trap 'rm -rf "$WORK"' EXIT
10
+ APPROVED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
11
+ EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%d)"
12
+ EXPIRED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
10
13
 
11
14
  mkdir -p "$WORK/compliance/security"
12
15
 
@@ -66,11 +69,16 @@ write_exception() {
66
69
  "expiresAt": "2026-12-31",
67
70
  "approvedBy": "reviewer@example.test",
68
71
  "reason": "Fixture acceptance while the introducing dependency awaits an upstream repair.",
69
- "remediationIssue": "https://github.com/example/repo/issues/1"
72
+ "remediationIssue": "https://github.com/example/repo/issues/1",
73
+ "compensatingControls": "Monitor upstream releases and retain the unaffected root package."
70
74
  }
71
75
  ]
72
76
  }
73
77
  JSON
78
+ jq --arg approved "$APPROVED_AT" --arg expires "$EXPIRES_AT" \
79
+ '.exceptions[0].approvedAt = $approved | .exceptions[0].expiresAt = $expires' \
80
+ "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
81
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
74
82
  }
75
83
 
76
84
  run_helper() {
@@ -107,7 +115,7 @@ jq -e '.summary.accepted == 0 and .summary.unresolved == 0' "$WORK/dependency-ri
107
115
  write_audit
108
116
  write_exception
109
117
  expect_success
110
- jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1"' \
118
+ jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1" and (.accepted[0].acceptance.compensatingControls | length > 0)' \
111
119
  "$WORK/dependency-risk-evaluation.json" >/dev/null
112
120
 
113
121
  jq '.exceptions[0].advisoryId = "GHSA-wrong"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
@@ -140,7 +148,7 @@ expect_failure
140
148
 
141
149
  write_exception
142
150
  write_audit
143
- jq '.exceptions[0].expiresAt = "2026-01-01"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
151
+ jq --arg expired "$EXPIRED_AT" '.exceptions[0].expiresAt = $expired' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
144
152
  mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
145
153
  expect_failure
146
154
 
@@ -150,6 +158,18 @@ jq '.exceptions[0].expiresAt = "not-a-date"' "$WORK/compliance/security/accepted
150
158
  mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
151
159
  expect_failure
152
160
 
161
+ write_exception
162
+ write_audit
163
+ jq '.exceptions[0].remediationIssue = "https://github.com/example/repo"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
164
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
165
+ expect_failure
166
+
167
+ write_exception
168
+ write_audit
169
+ jq '.exceptions[0].compensatingControls = []' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
170
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
171
+ expect_failure
172
+
153
173
  write_exception
154
174
  write_audit
155
175
  jq 'del(.exceptions[0].approvedAt)' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # Context-aware helpers for simple GitHub-flavoured Markdown tables.
3
+
4
+ markdown_table_cell() {
5
+ local file="$1"
6
+ local key_value="$2"
7
+ local target_header="$3"
8
+ shift 3
9
+ local required_headers=("$@")
10
+
11
+ [ -f "$file" ] || return 1
12
+ awk \
13
+ -v key_value="$key_value" \
14
+ -v target_header="$target_header" \
15
+ -v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
16
+ function clean(value) {
17
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
18
+ gsub(/\001/, "\\|", value)
19
+ return value
20
+ }
21
+ function split_row(line, cells, protected) {
22
+ protected=line
23
+ gsub(/\\\|/, "\001", protected)
24
+ return split(protected, cells, "|")
25
+ }
26
+ function header_matches(name, required, options, count, i) {
27
+ count=split(required, options, "/")
28
+ for (i=1; i<=count; i++) {
29
+ if (tolower(name) == tolower(options[i])) return 1
30
+ }
31
+ return 0
32
+ }
33
+ BEGIN {
34
+ required_count=split(required_headers, required, ",")
35
+ active=0
36
+ }
37
+ /^\|/ {
38
+ count=split_row($0, cells)
39
+ candidate_target=0
40
+ candidate_key=0
41
+ matched=0
42
+ for (i=2; i<count; i++) {
43
+ name=clean(cells[i])
44
+ if (tolower(name) == tolower(target_header)) candidate_target=i
45
+ for (r=1; r<=required_count; r++) {
46
+ if (header_matches(name, required[r])) matched++
47
+ }
48
+ if (header_matches(name, required[1])) candidate_key=i
49
+ }
50
+ if (candidate_target && candidate_key && matched >= required_count) {
51
+ active=1
52
+ target_index=candidate_target
53
+ key_index=candidate_key
54
+ next
55
+ }
56
+ if (active && key_index && target_index && clean(cells[key_index]) == key_value) {
57
+ print clean(cells[target_index])
58
+ found=1
59
+ exit 0
60
+ }
61
+ next
62
+ }
63
+ { active=0; key_index=0; target_index=0 }
64
+ END { if (!found) exit 1 }
65
+ ' "$file"
66
+ }
67
+
68
+ markdown_table_column_index_for_row() {
69
+ local file="$1"
70
+ local key_value="$2"
71
+ local target_header="$3"
72
+ shift 3
73
+ local required_headers=("$@")
74
+
75
+ [ -f "$file" ] || return 1
76
+ awk \
77
+ -v key_value="$key_value" \
78
+ -v target_header="$target_header" \
79
+ -v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
80
+ function clean(value) {
81
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
82
+ return value
83
+ }
84
+ function split_row(line, cells, protected) {
85
+ protected=line
86
+ gsub(/\\\|/, "\001", protected)
87
+ return split(protected, cells, "|")
88
+ }
89
+ function header_matches(name, required, options, count, i) {
90
+ count=split(required, options, "/")
91
+ for (i=1; i<=count; i++) {
92
+ if (tolower(name) == tolower(options[i])) return 1
93
+ }
94
+ return 0
95
+ }
96
+ BEGIN { required_count=split(required_headers, required, ","); active=0 }
97
+ /^\|/ {
98
+ count=split_row($0, cells)
99
+ candidate_target=0
100
+ candidate_key=0
101
+ matched=0
102
+ for (i=2; i<count; i++) {
103
+ name=clean(cells[i])
104
+ if (tolower(name) == tolower(target_header)) candidate_target=i
105
+ for (r=1; r<=required_count; r++) {
106
+ if (header_matches(name, required[r])) matched++
107
+ }
108
+ if (header_matches(name, required[1])) candidate_key=i
109
+ }
110
+ if (candidate_target && candidate_key && matched >= required_count) {
111
+ active=1
112
+ target_index=candidate_target
113
+ key_index=candidate_key
114
+ next
115
+ }
116
+ if (active && clean(cells[key_index]) == key_value) {
117
+ print target_index
118
+ found=1
119
+ exit 0
120
+ }
121
+ next
122
+ }
123
+ { active=0 }
124
+ END { if (!found) exit 1 }
125
+ ' "$file"
126
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ # shellcheck source=markdown-table.sh
6
+ source "$SCRIPT_DIR/markdown-table.sh"
7
+
8
+ WORK="$(mktemp -d)"
9
+ trap 'rm -rf "$WORK"' EXIT
10
+
11
+ cat > "$WORK/RTM.md" <<'MARKDOWN'
12
+ | Status | Description |
13
+ | --- | --- |
14
+ | Draft | This is not the requirements table |
15
+
16
+ | REQ-ID | Issue | Risk | Evidence | Status |
17
+ | --- | --- | --- | --- | --- |
18
+ | REQ-100 | #100 | HIGH | report.md | TESTED - PENDING SIGN-OFF (note \| retained) |
19
+ MARKDOWN
20
+
21
+ [ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = \
22
+ 'TESTED - PENDING SIGN-OFF (note \| retained)' ]
23
+ [ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Risk REQ-ID/ID Risk)" = "HIGH" ]
24
+ [ "$(markdown_table_column_index_for_row "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = "5" ]
25
+
26
+ if markdown_table_cell "$WORK/RTM.md" REQ-404 Status REQ-ID/ID Status >/dev/null; then
27
+ echo "missing row unexpectedly matched" >&2
28
+ exit 1
29
+ fi
30
+
31
+ if markdown_table_cell "$WORK/RTM.md" Draft Status REQ-ID/ID Status >/dev/null; then
32
+ echo "legend row unexpectedly matched" >&2
33
+ exit 1
34
+ fi
35
+
36
+ echo "markdown table tests passed"
@@ -12,6 +12,10 @@
12
12
 
13
13
  set -euo pipefail
14
14
 
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ # shellcheck source=markdown-table.sh
17
+ source "$SCRIPT_DIR/markdown-table.sh"
18
+
15
19
  BASE_BRANCH="${1:-origin/main}"
16
20
  EXIT_CODE=0
17
21
 
@@ -53,6 +57,7 @@ find_first_match() {
53
57
  local match=""
54
58
  match=$(compgen -G "$pattern" | head -1 || true)
55
59
  [ -n "$match" ] && printf '%s' "$match"
60
+ return 0
56
61
  }
57
62
 
58
63
  require_bundled_fields() {
@@ -81,7 +86,9 @@ for REQ in $REQUIREMENTS; do
81
86
  # The `| $REQ ` pattern (leading pipe + trailing space) matches the
82
87
  # markdown table row used in RTM, so a substring elsewhere doesn't
83
88
  # accidentally satisfy it.
84
- if ! grep -q "| $REQ " compliance/RTM.md 2>/dev/null; then
89
+ RTM_STATUS="$(markdown_table_cell \
90
+ compliance/RTM.md "$REQ" Status REQ-ID/ID Status 2>/dev/null || true)"
91
+ if [ -z "$RTM_STATUS" ]; then
85
92
  echo " INFO: $REQ is referenced in commits but has no RTM row — skipping (forward-reference, not a tracked requirement for this release)"
86
93
  continue
87
94
  fi
@@ -187,12 +194,12 @@ for REQ in $REQUIREMENTS; do
187
194
  # by a successor (e.g. REQ-030 superseded by REQ-031). Without this
188
195
  # branch every PR with a commit referencing a SUPERSEDED REQ blocks
189
196
  # CI (#232).
190
- if grep -q "$REQ" compliance/RTM.md 2>/dev/null; then
191
- if grep "$REQ" compliance/RTM.md | grep -q "TESTED - PENDING SIGN-OFF"; then
197
+ if [ -n "$RTM_STATUS" ]; then
198
+ if printf '%s' "$RTM_STATUS" | grep -q "TESTED - PENDING SIGN-OFF"; then
192
199
  echo " OK: RTM status is TESTED - PENDING SIGN-OFF"
193
- elif grep "$REQ" compliance/RTM.md | grep -q "APPROVED"; then
200
+ elif printf '%s' "$RTM_STATUS" | grep -q "APPROVED"; then
194
201
  echo " OK: RTM status is APPROVED"
195
- elif grep "$REQ" compliance/RTM.md | grep -q "SUPERSEDED"; then
202
+ elif printf '%s' "$RTM_STATUS" | grep -q "SUPERSEDED"; then
196
203
  echo " OK: RTM status is SUPERSEDED"
197
204
  else
198
205
  echo " WARNING: RTM entry exists but status is not TESTED - PENDING SIGN-OFF"
@@ -371,7 +378,9 @@ for REQ in $REQUIREMENTS; do
371
378
  fi
372
379
 
373
380
  # Check implementation-plan.md for MEDIUM/HIGH risk (check RTM for risk level)
374
- if grep "$REQ" compliance/RTM.md 2>/dev/null | grep -qiE 'MEDIUM|HIGH'; then
381
+ RTM_RISK="$(markdown_table_cell \
382
+ compliance/RTM.md "$REQ" Risk REQ-ID/ID Risk 2>/dev/null || true)"
383
+ if printf '%s' "$RTM_RISK" | grep -qiE 'MEDIUM|HIGH'; then
375
384
  if [ ! -f "compliance/evidence/$REQ/implementation-plan.md" ]; then
376
385
  echo " ERROR: Implementation plan missing for MEDIUM/HIGH risk: compliance/evidence/$REQ/implementation-plan.md"
377
386
  EXIT_CODE=1
@@ -341,7 +341,7 @@ Reached from Phase 0 for non-tracked change-types. The skill drives this end-to-
341
341
  - **Post-merge-only CI (older generated workflows — `push: branches: [<integration>]` with no `pull_request:` trigger)** — say so explicitly in the LAST/NEXT sticky: _"no PR-time checks will fire; review + merge is the gate; CI runs post-merge on `$INTEGRATION_BRANCH`."_ Don't poll the PR for checks that won't arrive. The post-merge run (CI Pipeline + Compliance Evidence Upload on the integration branch) is the actual gate; address it via fix-forward if it fails.
342
342
 
343
343
  Either way, never bypass a gate (no `--no-verify`, no `--admin` merge of a red required check); the only difference is **where** you wait for the gate to fire — before merge vs. after merge.
344
- 8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
344
+ 8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. If GitHub leaves a stale projection, use the repository's **Recover Required Checks** workflow: dry-run first, then apply only to rerun a resolvable completed Actions run on the same SHA. When GitHub never created the required suite, push an auditable empty commit to the PR branch and verify the new SHA; never fabricate status or bypass protection. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
345
345
  9. **Record the correct release meaning.** A housekeeping push produces at most a bare-date release (`vYYYY.MM.DD`) that is integration/history, not an active approval by default. It waits on `$INTEGRATION_BRANCH` until a later tracked release explicitly absorbs it through the required bundled-changes markdown/JSON manifest and portal lineage. Never infer that an unrelated hotfix or mandatory back-merge belongs to the only pending REQ. A submitted REQ bundle is frozen approval scope; changing it returns that REQ to evidence compilation and UAT resubmission. A doc-only push attaches documents to the existing `REQ-XXX` release.
346
346
  10. **Standalone exception.** Only when housekeeping cannot reasonably wait for the next tracked release may the skill prepare a standalone promotion. It must state `Standalone housekeeping promotion` and why in the release PR, add `compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-vYYYY.MM.DD.json` with `schemaVersion: 1`, the exact derived bare-date version, `releaseMode: standalone_housekeeping`, and a specific reason. It must obtain terminal-green CI and PR review. The installed release-scope check validates the declaration before merge; post-deploy uploads it as release-ticket evidence and changes the portal row to standalone housekeeping. Use portal approval only if project policy explicitly opts in. If the installed workflow cannot implement that policy, halt and keep the change on the normal integration path.
347
347
 
@@ -22,6 +22,10 @@ on:
22
22
  pull_request:
23
23
  branches: [main]
24
24
  workflow_dispatch:
25
+ inputs:
26
+ runner_label:
27
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
28
+ required: false
25
29
  repository_dispatch:
26
30
  types: [release-approved]
27
31
 
@@ -15,6 +15,10 @@ name: CI Pipeline
15
15
 
16
16
  on:
17
17
  workflow_dispatch:
18
+ inputs:
19
+ runner_label:
20
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
21
+ required: false
18
22
  pull_request:
19
23
  branches: [{{INTEGRATION_BRANCH}}]
20
24
  push:
@@ -178,7 +182,9 @@ jobs:
178
182
  {{E2E_DEV_SERVER_STEP}}
179
183
 
180
184
  - name: Wait for dev server
181
- run: npx wait-on http://localhost:3000 --timeout 120000
185
+ # The inner wait-on timeout is diagnostic; the outer deadline keeps a
186
+ # hung child from consuming the entire quality-gates job budget.
187
+ run: timeout --signal=TERM --kill-after=15s 150s npx wait-on http://localhost:3000 --timeout 120000
182
188
 
183
189
  # Compute the set of e2e spec files added on this branch (relative
184
190
  # to the merge base). The evidenceShot helper in the consumer's
@@ -352,8 +358,10 @@ jobs:
352
358
  fi
353
359
  chmod +x scripts/standalone-housekeeping-release.sh scripts/upload-evidence.sh 2>/dev/null || true
354
360
  bash scripts/standalone-housekeeping-release.sh validate "$VERSION" "$DECLARATION"
361
+ # A bare-date version identifies the release, not a requirement. Use
362
+ # the portal's common evidence scope while preserving release ownership.
355
363
  bash scripts/upload-evidence.sh \
356
- {{PROJECT_SLUG}} "$VERSION" release_ticket "$DECLARATION" \
364
+ {{PROJECT_SLUG}} _compliance-docs release_ticket "$DECLARATION" \
357
365
  --release "$VERSION" --create-release-if-missing \
358
366
  --environment uat --category release_artifact --sdlc-stage 2 \
359
367
  --git-sha ${{ github.sha }} --branch ${{ github.ref_name }}
@@ -28,6 +28,9 @@ on:
28
28
  release_pr:
29
29
  description: 'Release PR number or URL to backlink (optional)'
30
30
  required: false
31
+ runner_label:
32
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
33
+ required: false
31
34
  repository_dispatch:
32
35
  types: [release-closed]
33
36
 
@@ -21,6 +21,10 @@ name: Compliance Evidence Upload
21
21
 
22
22
  on:
23
23
  workflow_dispatch:
24
+ inputs:
25
+ runner_label:
26
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
27
+ required: false
24
28
  push:
25
29
  branches: [develop]
26
30
  paths:
@@ -443,16 +447,15 @@ jobs:
443
447
  DERIVED_RELEASE="${REQS[0]}"
444
448
  fi
445
449
 
446
- if [ "$PRIOR_EVENT" = "deployment_status" ] && [ "${#REQS[@]}" -eq 0 ]; then
447
- echo "::error::Deployment-origin E2E evidence requires tagged or in-scope REQ attribution; refusing _compliance-docs fallback."
448
- exit 1
449
- fi
450
-
451
450
  if [ "${#REQS[@]}" -eq 0 ]; then
452
451
  if echo "$DERIVED_RELEASE" | grep -qE '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}([.][0-9]+)?$'; then
453
452
  echo "No tracked REQ was executed for standalone/integration housekeeping ${DERIVED_RELEASE}; preserving this as GitHub historical CI without portal approval evidence."
454
453
  exit 0
455
454
  fi
455
+ if [ "$PRIOR_EVENT" = "deployment_status" ]; then
456
+ echo "::error::Deployment-origin E2E evidence requires tagged or in-scope REQ attribution; refusing _compliance-docs fallback."
457
+ exit 1
458
+ fi
456
459
  REQS=(_compliance-docs)
457
460
  fi
458
461
  # A bounded Playwright process exits the workflow with `failure`
@@ -61,7 +61,7 @@ jobs:
61
61
  - uses: actions/checkout@v6
62
62
  with:
63
63
  fetch-depth: 0
64
- - uses: actions/setup-node@v4
64
+ - uses: actions/setup-node@v6
65
65
  with:
66
66
  node-version: {{NODE_VERSION}}
67
67
  - name: Install dependencies (skip if lockfile unchanged)
@@ -84,7 +84,8 @@ jobs:
84
84
  {{E2E_DEV_SERVER_STEP}}
85
85
 
86
86
  - name: Wait for dev server
87
- run: npx wait-on http://localhost:3000 --timeout 120000
87
+ # Keep a process-level bound in addition to wait-on's internal timer.
88
+ run: timeout --signal=TERM --kill-after=15s 150s npx wait-on http://localhost:3000 --timeout 120000
88
89
 
89
90
  # Compute the set of e2e spec files added on this branch (relative
90
91
  # to the merge base). The evidenceShot helper reads E2E_NEW_SPECS
@@ -29,6 +29,9 @@ on:
29
29
  release:
30
30
  description: 'Optional REQ-XXX / version to promote (blank = all in-scope from pending release tickets).'
31
31
  required: false
32
+ runner_label:
33
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
34
+ required: false
32
35
  deployment_status:
33
36
  types: [created]
34
37
 
@@ -14,6 +14,10 @@ name: CI Pipeline
14
14
 
15
15
  on:
16
16
  workflow_dispatch:
17
+ inputs:
18
+ runner_label:
19
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
20
+ required: false
17
21
  pull_request:
18
22
  branches: [{{INTEGRATION_BRANCH}}]
19
23
  push:
@@ -311,11 +315,10 @@ jobs:
311
315
  name: Upload Evidence
312
316
  runs-on: {{RUNNER}}
313
317
  needs: [quality-gates, register-release]
314
- # `always()` instead of `!failure()` so failed gates still upload their
315
- # evidence `status=failed` is itself the audit trail. `!cancelled()`
316
- # still guards against partial state on operator-cancel.
317
- # DevAudit-Installer#96.
318
- if: ${{ always() && !cancelled() && github.ref_name == '{{INTEGRATION_BRANCH}}' && needs.register-release.result == 'success' }}
318
+ # Pull requests do not register or upload release evidence. Keeping this
319
+ # intentionally inapplicable job out of `always()` avoids a cancelled
320
+ # aggregate workflow when Register Release is correctly skipped (#500).
321
+ if: ${{ !cancelled() && github.event_name != 'pull_request' && github.ref_name == '{{INTEGRATION_BRANCH}}' && needs.register-release.result == 'success' }}
319
322
  env:
320
323
  DEVAUDIT_BASE_URL_VAR: ${{ vars.DEVAUDIT_BASE_URL }}
321
324
  DEVAUDIT_API_KEY: ${{ secrets.DEVAUDIT_API_KEY }}
@@ -24,6 +24,9 @@ on:
24
24
  health_url:
25
25
  description: 'Configured production health URL'
26
26
  required: true
27
+ runner_label:
28
+ description: 'Optional one-run runner label override. Blank uses repository CI_RUNNER_LABEL.'
29
+ required: false
27
30
 
28
31
  jobs:
29
32
  reconcile:
package/sdlc/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-sdlc",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "description": "DevAudit SDLC CLI Engine — cross-agent terminal-driven SDLC orchestrator.",
5
5
  "bin": {
6
6
  "devaudit-sdlc": "./src/bin/devaudit-sdlc.js"