@metasession.co/devaudit-cli 0.3.11 → 0.3.13

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.
Files changed (68) hide show
  1. package/dist/index.js +94 -6
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/scripts/upload-evidence.sh +55 -1
  5. package/sdlc/ai-rules/SDLC_RULES.md +27 -7
  6. package/sdlc/files/_common/0-project-setup.md +13 -7
  7. package/sdlc/files/_common/Periodic_Security_Review_Schedule.md +5 -5
  8. package/sdlc/files/_common/README_TEMPLATE.md +8 -8
  9. package/sdlc/files/_common/Test_Architecture.md +15 -15
  10. package/sdlc/files/_common/Test_Plan_TEMPLATE.md +13 -13
  11. package/sdlc/files/_common/Test_Policy.md +2 -2
  12. package/sdlc/files/_common/Test_Strategy.md +10 -10
  13. package/sdlc/files/_common/governance/ai-disclosure.md.template +3 -3
  14. package/sdlc/files/_common/governance/dpia.md.template +3 -3
  15. package/sdlc/files/_common/governance/incident-report.md.template +3 -3
  16. package/sdlc/files/_common/governance/nil-incident-report.md.template +1 -1
  17. package/sdlc/files/_common/governance/periodic-review.md.template +3 -3
  18. package/sdlc/files/_common/governance/risk-register.md.template +1 -1
  19. package/sdlc/files/_common/governance/ropa.md.template +2 -2
  20. package/sdlc/files/_common/joining-an-existing-project.md +4 -4
  21. package/sdlc/files/_common/scripts/check-host-deployment.sh +107 -0
  22. package/sdlc/files/_common/scripts/check-host-deployment.test.sh +119 -0
  23. package/sdlc/files/_common/scripts/check-release-pr-scope.sh +95 -0
  24. package/sdlc/files/_common/scripts/check-release-pr-scope.test.sh +137 -0
  25. package/sdlc/files/_common/scripts/close-out-release.sh +175 -54
  26. package/sdlc/files/_common/scripts/close-out-release.test.sh +90 -0
  27. package/sdlc/files/_common/scripts/extract-release-metadata.sh +65 -0
  28. package/sdlc/files/_common/scripts/extract-release-metadata.test.sh +38 -0
  29. package/sdlc/files/_common/scripts/generate-bundled-changes.sh +307 -58
  30. package/sdlc/files/_common/scripts/generate-bundled-changes.test.sh +128 -78
  31. package/sdlc/files/_common/scripts/generate-housekeeping-release-ticket.sh +18 -9
  32. package/sdlc/files/_common/scripts/generate-housekeeping-release-ticket.test.sh +66 -0
  33. package/sdlc/files/_common/scripts/generate-security-summary.sh +14 -5
  34. package/sdlc/files/_common/scripts/generate-security-summary.test.sh +57 -0
  35. package/sdlc/files/_common/scripts/prepare-release-pr.sh +44 -4
  36. package/sdlc/files/_common/scripts/render-test-cycles.sh +227 -0
  37. package/sdlc/files/_common/scripts/render-test-cycles.test.sh +107 -0
  38. package/sdlc/files/_common/scripts/report-test-cycle.sh +344 -0
  39. package/sdlc/files/_common/scripts/submit-bundle-manifest.sh +100 -0
  40. package/sdlc/files/_common/scripts/submit-bundle-manifest.test.sh +138 -0
  41. package/sdlc/files/_common/scripts/upload-evidence.sh +55 -1
  42. package/sdlc/files/_common/scripts/upload-evidence.test.sh +119 -0
  43. package/sdlc/files/_common/scripts/validate-compliance-artifacts.sh +169 -2
  44. package/sdlc/files/_common/scripts/validate-compliance-artifacts.test.sh +169 -0
  45. package/sdlc/files/_common/scripts/validate-test-summary.test.sh +4 -4
  46. package/sdlc/files/_common/skills/adr-author/SKILL.md +2 -2
  47. package/sdlc/files/_common/skills/e2e-test-engineer/SKILL.md +2 -2
  48. package/sdlc/files/_common/skills/e2e-test-engineer/references/bootstrap.md +5 -5
  49. package/sdlc/files/_common/skills/governance-doc-author/references/incident-classification.md +1 -1
  50. package/sdlc/files/_common/skills/requirements-aligner/SKILL.md +4 -4
  51. package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +57 -3
  52. package/sdlc/files/_common/skills/sdlc-implementer/references/change-request-loop.md +1 -1
  53. package/sdlc/files/_common/skills/sdlc-implementer/references/compliance-constraints.md +7 -14
  54. package/sdlc/files/ci/check-release-approval.yml.template +18 -28
  55. package/sdlc/files/ci/ci.yml.template +95 -35
  56. package/sdlc/files/ci/close-out-release.yml.template +7 -4
  57. package/sdlc/files/ci/compliance-evidence.yml.template +234 -49
  58. package/sdlc/files/ci/feature-e2e.yml.template +84 -1
  59. package/sdlc/files/ci/post-deploy-prod.yml.template +59 -4
  60. package/sdlc/files/ci/python/ci.yml.template +60 -12
  61. package/sdlc/files/ci/quality-gates-provenance.yml.template +123 -28
  62. package/sdlc/package.json +1 -1
  63. package/sdlc/src/bin/devaudit-sdlc.js +44 -2
  64. package/sdlc/src/blueprints/1-plan-requirement.raw.md +6 -1
  65. package/sdlc/src/blueprints/2-implement-and-test.raw.md +1 -1
  66. package/sdlc/src/blueprints/3-compile-evidence.raw.md +102 -20
  67. package/sdlc/src/blueprints/4-submit-for-review.raw.md +3 -3
  68. package/sdlc/src/blueprints/5-deploy-main.raw.md +2 -2
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # check-release-pr-scope.test.sh — Tests for release PR metadata drift checks.
3
+
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ HELPER="$SCRIPT_DIR/check-release-pr-scope.sh"
8
+
9
+ PASS=0
10
+ FAIL=0
11
+ WORK=$(mktemp -d)
12
+ trap 'rm -rf "$WORK"' EXIT
13
+
14
+ assert_exit() {
15
+ local name="$1" expected="$2" actual="$3"
16
+ if [ "$expected" = "$actual" ]; then
17
+ echo " ✓ $name"
18
+ PASS=$((PASS + 1))
19
+ else
20
+ echo " ✗ $name"
21
+ echo " Expected exit: $expected"
22
+ echo " Actual exit: $actual"
23
+ FAIL=$((FAIL + 1))
24
+ fi
25
+ }
26
+
27
+ assert_grep() {
28
+ local name="$1" pattern="$2" file="$3"
29
+ if grep -Eq "$pattern" "$file"; then
30
+ echo " ✓ $name"
31
+ PASS=$((PASS + 1))
32
+ else
33
+ echo " ✗ $name"
34
+ echo " Missing pattern: $pattern"
35
+ FAIL=$((FAIL + 1))
36
+ fi
37
+ }
38
+
39
+ make_fixture() {
40
+ local dir="$1" subject="$2"
41
+ rm -rf "$dir"
42
+ mkdir -p "$dir/scripts" "$dir/compliance/pending-releases"
43
+ cp "$SCRIPT_DIR/derive-release-version.sh" "$dir/scripts/derive-release-version.sh"
44
+ cp "$SCRIPT_DIR/extract-release-metadata.sh" "$dir/scripts/extract-release-metadata.sh"
45
+ cp "$HELPER" "$dir/scripts/check-release-pr-scope.sh"
46
+ chmod +x "$dir/scripts/"*.sh
47
+ (
48
+ cd "$dir"
49
+ git init -q
50
+ git config user.email test@example.com
51
+ git config user.name tester
52
+ git commit --allow-empty -q -m "$subject"
53
+ )
54
+ }
55
+
56
+ run_check() {
57
+ local dir="$1" out="$2"
58
+ shift 2
59
+ (
60
+ cd "$dir"
61
+ "$@" bash scripts/check-release-pr-scope.sh
62
+ ) >"$out" 2>&1
63
+ }
64
+
65
+ echo "=== check-release-pr-scope.sh tests ==="
66
+ echo ""
67
+
68
+ echo "--- Test 1: matching REQ scope passes ---"
69
+ make_fixture "$WORK/test1" "[REQ-093] feat: ship release"
70
+ cat > "$WORK/test1/compliance/pending-releases/RELEASE-TICKET-REQ-093.md" <<'EOF'
71
+ # Release Ticket — REQ-093
72
+
73
+ **Requirement:** REQ-093 — Example release
74
+
75
+ ## Summary
76
+ Normal tracked release.
77
+ EOF
78
+ OUT="$WORK/test1.out"
79
+ if run_check "$WORK/test1" "$OUT" env PR_TITLE="Release: REQ-093" PR_BODY=$'## Release\n- Release: REQ-093\n'; then
80
+ CODE=0
81
+ else
82
+ CODE=$?
83
+ fi
84
+ assert_exit "matching REQ scope passes" 0 "$CODE"
85
+ assert_grep "success message emitted" 'Release Scope Integrity verified for REQ-093' "$OUT"
86
+ echo ""
87
+
88
+ echo "--- Test 2: stale REQ scope fails ---"
89
+ make_fixture "$WORK/test2" "[REQ-093] feat: ship release"
90
+ OUT="$WORK/test2.out"
91
+ if run_check "$WORK/test2" "$OUT" env PR_TITLE="Release: REQ-092" PR_BODY=$'## Release\n- Release: REQ-092\n'; then
92
+ CODE=0
93
+ else
94
+ CODE=$?
95
+ fi
96
+ assert_exit "stale REQ scope fails" 1 "$CODE"
97
+ assert_grep "declared scope printed" 'Declared PR scope: REQ-092' "$OUT"
98
+ assert_grep "derived scope printed" 'Derived effective scope: REQ-093' "$OUT"
99
+ echo ""
100
+
101
+ echo "--- Test 3: bundled release must mention bundled marker ---"
102
+ make_fixture "$WORK/test3" "[REQ-093] feat: bundled release"
103
+ cat > "$WORK/test3/compliance/pending-releases/RELEASE-TICKET-REQ-093.md" <<'EOF'
104
+ # Release Ticket — REQ-093
105
+
106
+ **Requirement:** REQ-093 — Example release
107
+
108
+ ## Summary
109
+ Tracked release summary.
110
+ EOF
111
+ cat > "$WORK/test3/compliance/pending-releases/BUNDLED-CHANGES-REQ-093.md" <<'EOF'
112
+ # Bundled Changes — REQ-093
113
+ EOF
114
+ OUT="$WORK/test3.out"
115
+ if run_check "$WORK/test3" "$OUT" env PR_TITLE="Release: REQ-093" PR_BODY=$'## Release\n- Release: REQ-093\n'; then
116
+ CODE=0
117
+ else
118
+ CODE=$?
119
+ fi
120
+ assert_exit "missing bundled marker fails" 1 "$CODE"
121
+ assert_grep "bundled mismatch printed" 'bundled release context marker' "$OUT"
122
+ echo ""
123
+
124
+ echo "--- Test 4: hotfix branches skip scope integrity ---"
125
+ make_fixture "$WORK/test4" "[REQ-093] feat: ship release"
126
+ OUT="$WORK/test4.out"
127
+ if run_check "$WORK/test4" "$OUT" env HEAD_REF="hotfix/urgent-fix" PR_TITLE="fix: urgent prod bug" PR_BODY="HOTFIX"; then
128
+ CODE=0
129
+ else
130
+ CODE=$?
131
+ fi
132
+ assert_exit "hotfix branch skips" 0 "$CODE"
133
+ assert_grep "hotfix skip message" 'Hotfix PR detected' "$OUT"
134
+ echo ""
135
+
136
+ echo "=== check-release-pr-scope.test.sh: $PASS passed, $FAIL failed ==="
137
+ [ "$FAIL" -eq 0 ]
@@ -2,11 +2,20 @@
2
2
  # close-out-release.sh — Reconcile the local compliance tree after a release
3
3
  # is marked `released` on the DevAudit portal.
4
4
  #
5
- # For one requirement it: flips the release ticket Status -> RELEASED (backlinks
6
- # the release PR + records the sign-off), flips the matching compliance/RTM.md
7
- # row -> RELEASED, and moves the ticket from compliance/pending-releases/ to
8
- # compliance/approved-releases/. It stages the changes but does NOT commit — the
9
- # caller (the close-out workflow, or a human) commits/opens the PR.
5
+ # For the governing requirement it: flips the release ticket Status -> RELEASED
6
+ # (backlinks the release PR + records the sign-off), flips the matching
7
+ # compliance/RTM.md row -> RELEASED, and moves the ticket from
8
+ # compliance/pending-releases/ to compliance/approved-releases/.
9
+ #
10
+ # If the release carries a bundle manifest, explicit predecessor members are
11
+ # reconciled too: their release tickets move to compliance/superseded-releases/,
12
+ # ticket status flips to SUPERSEDED, the successor + reason are recorded, and
13
+ # tracked REQ rows in compliance/RTM.md flip to SUPERSEDED. This keeps absorbed
14
+ # predecessor releases from looking abandoned after the successor approval
15
+ # envelope closes.
16
+ #
17
+ # The script stages the changes but does NOT commit — the caller (the close-out
18
+ # workflow, or a human) commits/opens the PR.
10
19
  #
11
20
  # Usage:
12
21
  # ./scripts/close-out-release.sh <REQ-ID> [--release-pr <url-or-number>]
@@ -42,10 +51,147 @@ fi
42
51
 
43
52
  PENDING="compliance/pending-releases/RELEASE-TICKET-${REQ_ID}.md"
44
53
  APPROVED_DIR="compliance/approved-releases"
54
+ SUPERSEDED_DIR="compliance/superseded-releases"
45
55
  APPROVED="${APPROVED_DIR}/RELEASE-TICKET-${REQ_ID}.md"
46
56
  RTM="compliance/RTM.md"
47
57
  TODAY="$(date +%Y-%m-%d)"
48
58
 
59
+ find_bundle_manifest_file() {
60
+ local req_id="$1"
61
+ local candidate=""
62
+ for candidate in \
63
+ "compliance/pending-releases/BUNDLED-CHANGES-${req_id}.json" \
64
+ "compliance/approved-releases/BUNDLED-CHANGES-${req_id}.json" \
65
+ "compliance/superseded-releases/BUNDLED-CHANGES-${req_id}.json"; do
66
+ if [ -f "$candidate" ]; then
67
+ printf '%s' "$candidate"
68
+ return 0
69
+ fi
70
+ done
71
+ return 1
72
+ }
73
+
74
+ find_release_ticket_file() {
75
+ local version="$1"
76
+ local candidate=""
77
+ for candidate in \
78
+ "compliance/pending-releases/RELEASE-TICKET-${version}.md" \
79
+ "compliance/approved-releases/RELEASE-TICKET-${version}.md" \
80
+ "compliance/superseded-releases/RELEASE-TICKET-${version}.md"; do
81
+ if [ -f "$candidate" ]; then
82
+ printf '%s' "$candidate"
83
+ return 0
84
+ fi
85
+ done
86
+ return 1
87
+ }
88
+
89
+ update_rtm_status() {
90
+ local req_id="$1"
91
+ local target_status="$2"
92
+ local tmp_file
93
+ 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 }
97
+ /\\\|/ { 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
+ $0 ~ ("^\\| " req " ") && statuscol>0 {
108
+ cell=$statuscol
109
+ note=""
110
+ if (match(cell, /\(/)) note=substr(cell, RSTART)
111
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", note)
112
+ $statuscol = (note != "" ? " " target_status " " note " " : " " target_status " ")
113
+ gsub(/\001/, "\\|", $0)
114
+ print; next
115
+ }
116
+ { gsub(/\001/, "\\|", $0); print }
117
+ ' "$RTM" > "$tmp_file" && mv "$tmp_file" "$RTM"
118
+ git add "$RTM" 2>/dev/null || true
119
+ echo "RTM row ${req_id} -> ${target_status}."
120
+ else
121
+ rm -f "$tmp_file"
122
+ fi
123
+ }
124
+
125
+ mark_ticket_superseded() {
126
+ local version="$1"
127
+ local reason="$2"
128
+ local relationship="$3"
129
+ local source_path target_path tmp_file
130
+ source_path="$(find_release_ticket_file "$version" 2>/dev/null || true)"
131
+ [ -n "$source_path" ] || return 0
132
+
133
+ mkdir -p "$SUPERSEDED_DIR"
134
+ target_path="${SUPERSEDED_DIR}/RELEASE-TICKET-${version}.md"
135
+ if [ "$source_path" != "$target_path" ]; then
136
+ git mv "$source_path" "$target_path" 2>/dev/null || mv "$source_path" "$target_path"
137
+ echo "Moved predecessor ticket -> ${target_path}"
138
+ fi
139
+
140
+ tmp_file="$(mktemp)"
141
+ awk \
142
+ -v successor="$REQ_ID" \
143
+ -v superseded_on="$TODAY" \
144
+ -v superseded_reason="$reason" \
145
+ -v superseded_relationship="$relationship" '
146
+ BEGIN {
147
+ status_done=0
148
+ successor_seen=0
149
+ reason_seen=0
150
+ relationship_seen=0
151
+ date_seen=0
152
+ }
153
+ /^\*\*Status:\*\*/ && status_done==0 {
154
+ print "**Status:** SUPERSEDED"
155
+ status_done=1
156
+ next
157
+ }
158
+ /^\*\*Superseded by:\*\*/ {
159
+ print "**Superseded by:** " successor
160
+ successor_seen=1
161
+ next
162
+ }
163
+ /^\*\*Supersession reason:\*\*/ {
164
+ print "**Supersession reason:** " superseded_reason
165
+ reason_seen=1
166
+ next
167
+ }
168
+ /^\*\*Supersession relationship:\*\*/ {
169
+ print "**Supersession relationship:** " superseded_relationship
170
+ relationship_seen=1
171
+ next
172
+ }
173
+ /^\*\*Superseded on:\*\*/ {
174
+ print "**Superseded on:** " superseded_on
175
+ date_seen=1
176
+ next
177
+ }
178
+ { print }
179
+ /^\*\*DevAudit Release:\*\*/ {
180
+ if (!successor_seen) print "**Superseded by:** " successor
181
+ if (!reason_seen) print "**Supersession reason:** " superseded_reason
182
+ if (!relationship_seen) print "**Supersession relationship:** " superseded_relationship
183
+ if (!date_seen) print "**Superseded on:** " superseded_on
184
+ }
185
+ ' "$target_path" > "$tmp_file"
186
+ mv "$tmp_file" "$target_path"
187
+ git add "$target_path" 2>/dev/null || true
188
+ echo "Ticket ${version} -> SUPERSEDED."
189
+
190
+ if printf '%s' "$version" | grep -qE '^REQ-[0-9]{3,}$'; then
191
+ update_rtm_status "$version" "SUPERSEDED"
192
+ fi
193
+ }
194
+
49
195
  # ── Optional portal safety check ─────────────────────────────────────────────
50
196
  if [ -n "${DEVAUDIT_API_KEY:-}" ] && [ -n "${DEVAUDIT_BASE_URL:-}" ]; then
51
197
  BASE="${DEVAUDIT_BASE_URL%/}"
@@ -76,6 +222,7 @@ fi
76
222
 
77
223
  # ── Move ticket pending -> approved (if still pending) ───────────────────────
78
224
  mkdir -p "$APPROVED_DIR"
225
+ mkdir -p "$SUPERSEDED_DIR"
79
226
  if [ -f "$PENDING" ]; then
80
227
  git mv "$PENDING" "$APPROVED" 2>/dev/null || mv "$PENDING" "$APPROVED"
81
228
  echo "Moved ticket -> ${APPROVED}"
@@ -115,56 +262,30 @@ mv "$TMP" "$APPROVED"
115
262
  git add "$APPROVED" 2>/dev/null || true
116
263
  echo "Ticket Status -> RELEASED."
117
264
 
118
- # ── Flip the RTM row -> RELEASED (preserve any parenthetical note) ───────────
119
- if [ -f "$RTM" ] && grep -qE "^\| ${REQ_ID} " "$RTM"; then
120
- # Table-aware Status column resolution (#72): the previous version locked
121
- # `statuscol` on the FIRST header that contained "Status", which mangled the
122
- # wrong column when the RTM has a small legend table above the main matrix
123
- # (e.g. a 2-column legend with `Status | Description` columns → statuscol=2,
124
- # then the awk overwrote col-1 REQ-ID for every row).
125
- #
126
- # Fix: re-evaluate `statuscol` on EVERY header-shaped row (a row whose cells
127
- # carry the literal header text "Status" + an ID-like column header). The
128
- # legend has "Status" but no ID-like column → not locked; the main RTM has
129
- # both → locks correctly. Data rows don't carry the literal "Status" header
130
- # text in any cell, so they don't re-trigger the lock. Separator rows
131
- # (`|---|---|…`) are left intact and don't affect `statuscol`.
132
- awk -v req="$REQ_ID" '
133
- BEGIN { FS="|"; OFS="|"; statuscol=0 }
134
- # Protect escaped pipes (\|) from being treated as column delimiters.
135
- # Replacing \| with \001 in $0 forces awk to re-split on only unescaped
136
- # pipes, so literal pipes inside cell content (e.g. regex patterns like
137
- # stop\|unsubscribe, enum values open\|in_progress\|closed) do not create
138
- # phantom extra columns. Placeholders are restored to \| before output.
139
- /\\\|/ { gsub(/\\\|/, "\001", $0) }
140
- # Header detection: scan every row; require both a "Status" header cell
141
- # AND an ID-like header cell in the same row before locking statuscol to
142
- # this row''s column index.
143
- {
144
- cand=0; idseen=0
145
- for (i=1; i<=NF; i++) {
146
- c=$i; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
147
- if (c=="Status") cand=i
148
- if (c=="ID" || c=="REQ-ID" || c=="REQ ID" || c ~ /^Requirement/) idseen=1
149
- }
150
- if (cand>0 && idseen) statuscol=cand
151
- }
152
- $0 ~ ("^\\| " req " ") && statuscol>0 {
153
- cell=$statuscol
154
- note=""
155
- if (match(cell, /\(/)) note=substr(cell, RSTART) # preserve any " (requirement note)"
156
- gsub(/^[[:space:]]+|[[:space:]]+$/, "", note)
157
- $statuscol = (note != "" ? " RELEASED " note " " : " RELEASED ")
158
- gsub(/\001/, "\\|", $0)
159
- print; next
160
- }
161
- { gsub(/\001/, "\\|", $0); print }
162
- ' "$RTM" > "$TMP" && mv "$TMP" "$RTM"
163
- git add "$RTM" 2>/dev/null || true
164
- echo "RTM row ${REQ_ID} -> RELEASED."
165
- else
265
+ update_rtm_status "$REQ_ID" "RELEASED" || true
266
+ if ! grep -qE "^\| ${REQ_ID} " "$RTM" 2>/dev/null; then
166
267
  echo "::warning::No RTM row for ${REQ_ID} in ${RTM} — skipped RTM flip."
167
- rm -f "$TMP"
268
+ fi
269
+
270
+ # ── Reconcile explicit predecessors from the bundle manifest ──────────────────
271
+ BUNDLE_MANIFEST="$(find_bundle_manifest_file "$REQ_ID" 2>/dev/null || true)"
272
+ if [ -n "$BUNDLE_MANIFEST" ] && command -v jq >/dev/null 2>&1; then
273
+ while IFS=$'\t' read -r member_version member_reason member_relationship; do
274
+ [ -n "$member_version" ] || continue
275
+ mark_ticket_superseded "$member_version" "$member_reason" "$member_relationship"
276
+ done < <(
277
+ jq -r --arg successor "$REQ_ID" '
278
+ (.members // [])
279
+ | map(select((.relationship // "") == "superseded" or (.relationship // "") == "absorbed"))
280
+ | .[]
281
+ | [
282
+ (.version // ""),
283
+ (.reason // ("Absorbed into successor release " + $successor + ".")),
284
+ (.relationship // "superseded")
285
+ ]
286
+ | @tsv
287
+ ' "$BUNDLE_MANIFEST" 2>/dev/null || true
288
+ )
168
289
  fi
169
290
 
170
291
  echo "Close-out staged for ${REQ_ID}. Commit + open a PR to develop to land it."
@@ -195,6 +195,96 @@ EOF
195
195
  rm -rf "$(dirname "$dir")"
196
196
  }
197
197
 
198
+ # ── Case 4: bundled close-out supersedes predecessor tickets ──────────────────
199
+ {
200
+ dir="$(mktemp -d)/cli-close-out-fixture-4"
201
+ mkdir -p "$dir/compliance/pending-releases" \
202
+ "$dir/compliance/approved-releases" \
203
+ "$dir/compliance/superseded-releases"
204
+ cd "$dir"
205
+ git init -q --initial-branch=main >/dev/null
206
+ git config user.email "test@example.com"
207
+ git config user.name "test"
208
+ cat > compliance/RTM.md <<'EOF'
209
+ # Requirements Traceability Matrix
210
+
211
+ | REQ-ID | Source | Risk | Evidence | Status | Owner | Date |
212
+ | ------- | ------ | ---- | -------- | ------ | ----- | ---- |
213
+ | REQ-090 | #500 | HIGH | compliance/evidence/REQ-090/ | TESTED - PENDING SIGN-OFF | test | 2026-07-16 |
214
+ | REQ-089 | #499 | LOW | compliance/evidence/REQ-089/ | TESTED - PENDING SIGN-OFF | test | 2026-07-15 |
215
+ EOF
216
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-090.md <<'EOF'
217
+ # Release Ticket: REQ-090
218
+
219
+ **Status:** TESTED - PENDING SIGN-OFF
220
+ **DevAudit Release:** REQ-090
221
+ EOF
222
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-089.md <<'EOF'
223
+ # Release Ticket: REQ-089
224
+
225
+ **Status:** TESTED - PENDING SIGN-OFF
226
+ **DevAudit Release:** REQ-089
227
+ EOF
228
+ cat > compliance/pending-releases/RELEASE-TICKET-v2026.07.10.md <<'EOF'
229
+ # Release Ticket: v2026.07.10
230
+
231
+ **Status:** TESTED - PENDING SIGN-OFF
232
+ **DevAudit Release:** v2026.07.10
233
+ EOF
234
+ cat > compliance/pending-releases/BUNDLED-CHANGES-REQ-090.json <<'EOF'
235
+ {
236
+ "schemaVersion": 1,
237
+ "approvalRelease": { "version": "REQ-090" },
238
+ "coreRelease": { "version": "REQ-090" },
239
+ "members": [
240
+ {
241
+ "version": "REQ-089",
242
+ "role": "predecessor",
243
+ "relationship": "superseded",
244
+ "reason": "Explicit predecessor release absorbed into approval envelope REQ-090."
245
+ },
246
+ {
247
+ "version": "v2026.07.10",
248
+ "role": "housekeeping",
249
+ "relationship": "absorbed",
250
+ "reason": "Explicit housekeeping release ticket carried forward into approval envelope REQ-090."
251
+ }
252
+ ],
253
+ "nonReleaseWorkItems": [],
254
+ "manifestHash": "sha256:test"
255
+ }
256
+ EOF
257
+ git add -A
258
+ git commit -q -m "fixture: bundled close-out"
259
+ unset DEVAUDIT_API_KEY DEVAUDIT_BASE_URL || true
260
+ bash "$HELPER" REQ-090 >/dev/null 2>&1 || true
261
+
262
+ [ -f compliance/approved-releases/RELEASE-TICKET-REQ-090.md ] \
263
+ && assert_eq "current release moved to approved-releases" "yes" "yes" \
264
+ || assert_eq "current release moved to approved-releases" "yes" "no"
265
+ [ -f compliance/superseded-releases/RELEASE-TICKET-REQ-089.md ] \
266
+ && assert_eq "predecessor moved to superseded-releases" "yes" "yes" \
267
+ || assert_eq "predecessor moved to superseded-releases" "yes" "no"
268
+ [ -f compliance/superseded-releases/RELEASE-TICKET-v2026.07.10.md ] \
269
+ && assert_eq "housekeeping predecessor moved to superseded-releases" "yes" "yes" \
270
+ || assert_eq "housekeeping predecessor moved to superseded-releases" "yes" "no"
271
+
272
+ grep -qF '**Status:** SUPERSEDED' compliance/superseded-releases/RELEASE-TICKET-REQ-089.md \
273
+ && assert_eq "predecessor status flipped" "yes" "yes" \
274
+ || assert_eq "predecessor status flipped" "yes" "no"
275
+ grep -qF '**Superseded by:** REQ-090' compliance/superseded-releases/RELEASE-TICKET-REQ-089.md \
276
+ && assert_eq "predecessor successor recorded" "yes" "yes" \
277
+ || assert_eq "predecessor successor recorded" "yes" "no"
278
+ grep -qF 'Explicit predecessor release absorbed into approval envelope REQ-090.' compliance/superseded-releases/RELEASE-TICKET-REQ-089.md \
279
+ && assert_eq "predecessor reason recorded" "yes" "yes" \
280
+ || assert_eq "predecessor reason recorded" "yes" "no"
281
+
282
+ row=$(grep -m1 -E "^\| REQ-089 " compliance/RTM.md || true)
283
+ col5=$(echo "$row" | awk -F '|' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$6); print $6}')
284
+ assert_eq "predecessor RTM row flipped" "SUPERSEDED" "$col5"
285
+ rm -rf "$(dirname "$dir")"
286
+ }
287
+
198
288
  echo
199
289
  echo "Result: $PASS passed, $FAIL failed"
200
290
  [ "$FAIL" = "0" ]
@@ -26,6 +26,36 @@
26
26
 
27
27
  set -euo pipefail
28
28
 
29
+ find_bundled_changes_file() {
30
+ local req_id="$1"
31
+ local candidate=""
32
+ for candidate in \
33
+ "compliance/pending-releases/BUNDLED-CHANGES-${req_id}.md" \
34
+ "compliance/approved-releases/BUNDLED-CHANGES-${req_id}.md" \
35
+ "compliance/superseded-releases/BUNDLED-CHANGES-${req_id}.md"; do
36
+ if [ -f "$candidate" ]; then
37
+ printf '%s' "$candidate"
38
+ return 0
39
+ fi
40
+ done
41
+ return 1
42
+ }
43
+
44
+ find_bundled_manifest_file() {
45
+ local req_id="$1"
46
+ local candidate=""
47
+ for candidate in \
48
+ "compliance/pending-releases/BUNDLED-CHANGES-${req_id}.json" \
49
+ "compliance/approved-releases/BUNDLED-CHANGES-${req_id}.json" \
50
+ "compliance/superseded-releases/BUNDLED-CHANGES-${req_id}.json"; do
51
+ if [ -f "$candidate" ]; then
52
+ printf '%s' "$candidate"
53
+ return 0
54
+ fi
55
+ done
56
+ return 1
57
+ }
58
+
29
59
  extract_release_metadata() {
30
60
  local req_id="$1"
31
61
  RELEASE_TITLE=""
@@ -116,4 +146,39 @@ extract_release_metadata() {
116
146
  ;;
117
147
  esac
118
148
  fi
149
+
150
+ # If the tracked release has a generated bundled-changes artefact,
151
+ # surface that fact in the release summary so the portal release row and
152
+ # workflow metadata don't rely on the GitHub PR body as the only bundle
153
+ # narrative. DevAudit-Installer#344.
154
+ local bundled_file=""
155
+ bundled_file=$(find_bundled_changes_file "$req_id" 2>/dev/null || true)
156
+ if [ -n "$bundled_file" ]; then
157
+ local bundled_note
158
+ local bundled_manifest=""
159
+ local manifest_hash=""
160
+ bundled_manifest=$(find_bundled_manifest_file "$req_id" 2>/dev/null || true)
161
+ if [ -n "$bundled_manifest" ] && command -v jq >/dev/null 2>&1; then
162
+ manifest_hash=$(jq -r '.manifestHash // empty' "$bundled_manifest" 2>/dev/null || true)
163
+ fi
164
+ bundled_note="Bundled release context: see \`${bundled_file}\`."
165
+ if [ -n "$bundled_manifest" ]; then
166
+ bundled_note="${bundled_note} Manifest: \`${bundled_manifest}\`."
167
+ fi
168
+ if [ -n "$manifest_hash" ]; then
169
+ bundled_note="${bundled_note} Hash: \`${manifest_hash}\`."
170
+ fi
171
+ if [ -n "$RELEASE_SUMMARY" ]; then
172
+ case "$RELEASE_SUMMARY" in
173
+ *"Bundled release context:"*) ;;
174
+ *)
175
+ RELEASE_SUMMARY="${RELEASE_SUMMARY}
176
+
177
+ ${bundled_note}"
178
+ ;;
179
+ esac
180
+ else
181
+ RELEASE_SUMMARY="$bundled_note"
182
+ fi
183
+ fi
119
184
  }
@@ -358,6 +358,44 @@ assert_eq "Title extracted" "Feature fifteen" "$RELEASE_TITLE"
358
358
  assert_empty "Whitespace-only summary cleared" "$RELEASE_SUMMARY"
359
359
  echo ""
360
360
 
361
+ # --- Test 17: Bundled changes file appends bundle note to summary ---
362
+ echo "--- Test 17: Bundled changes note appended ---"
363
+ make_fixture "$WORK/test17"
364
+ cat > "compliance/pending-releases/RELEASE-TICKET-REQ-017.md" <<'TICKET'
365
+ # Release Ticket — REQ-017
366
+
367
+ **Requirement:** REQ-017 — Feature seventeen
368
+
369
+ ## Summary
370
+ Core tracked change summary.
371
+ TICKET
372
+ cat > "compliance/pending-releases/BUNDLED-CHANGES-REQ-017.md" <<'BUNDLE'
373
+ ## Bundled Changes
374
+
375
+ - `abc1234` chore: sync templates
376
+ BUNDLE
377
+ cat > "compliance/pending-releases/BUNDLED-CHANGES-REQ-017.json" <<'BUNDLEJSON'
378
+ {
379
+ "schemaVersion": 1,
380
+ "approvalRelease": { "version": "REQ-017" },
381
+ "coreRelease": { "version": "REQ-017" },
382
+ "members": [],
383
+ "nonReleaseWorkItems": [
384
+ { "kind": "housekeeping_commit", "title": "chore: sync templates", "reference": "abc1234" }
385
+ ],
386
+ "manifestHash": "sha256:test-bundle-hash"
387
+ }
388
+ BUNDLEJSON
389
+
390
+ source "$HELPER"
391
+ extract_release_metadata "REQ-017"
392
+ assert_eq "Bundled note appended to summary" \
393
+ "Core tracked change summary.
394
+
395
+ Bundled release context: see \`compliance/pending-releases/BUNDLED-CHANGES-REQ-017.md\`. Manifest: \`compliance/pending-releases/BUNDLED-CHANGES-REQ-017.json\`. Hash: \`sha256:test-bundle-hash\`." \
396
+ "$RELEASE_SUMMARY"
397
+ echo ""
398
+
361
399
  echo ""
362
400
  echo "=== Results: $PASS passed, $FAIL failed ==="
363
401
  exit $FAIL