@metasession.co/devaudit-cli 0.3.16 → 0.3.18

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.16",
3
+ "version": "0.3.18",
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.16",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.18",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -8,6 +8,7 @@ REPO=""
8
8
  SHA=""
9
9
  MAX_ATTEMPTS=30
10
10
  POLL_SECONDS=10
11
+ OUTPUT_FILE=""
11
12
 
12
13
  for arg in "$@"; do
13
14
  case "$arg" in
@@ -15,6 +16,7 @@ for arg in "$@"; do
15
16
  --sha=*) SHA="${arg#*=}" ;;
16
17
  --max-attempts=*) MAX_ATTEMPTS="${arg#*=}" ;;
17
18
  --poll-seconds=*) POLL_SECONDS="${arg#*=}" ;;
19
+ --output-file=*) OUTPUT_FILE="${arg#*=}" ;;
18
20
  *)
19
21
  echo "Unknown argument: $arg" >&2
20
22
  exit 2
@@ -23,7 +25,7 @@ for arg in "$@"; do
23
25
  done
24
26
 
25
27
  if [ -z "$REPO" ] || [ -z "$SHA" ]; then
26
- echo "Usage: check-host-deployment.sh --repo=<owner/name> --sha=<sha> [--max-attempts=N] [--poll-seconds=N]" >&2
28
+ echo "Usage: check-host-deployment.sh --repo=<owner/name> --sha=<sha> [--max-attempts=N] [--poll-seconds=N] [--output-file=path]" >&2
27
29
  exit 2
28
30
  fi
29
31
 
@@ -64,43 +66,91 @@ if statuses:
64
66
  PY
65
67
  }
66
68
 
69
+ extract_status_details() {
70
+ python3 - <<'PY'
71
+ import json
72
+ import os
73
+
74
+ statuses = json.loads(os.environ.get("STATUSES_JSON", "[]"))
75
+ latest = statuses[0] if statuses else {}
76
+ print(json.dumps({
77
+ "state": str(latest.get("state") or "").lower(),
78
+ "description": str(latest.get("description") or ""),
79
+ "target_url": str(latest.get("target_url") or latest.get("environment_url") or ""),
80
+ "environment": str(latest.get("environment") or ""),
81
+ }, separators=(",", ":")))
82
+ PY
83
+ }
84
+
85
+ json_field() {
86
+ STATUS_DETAILS="$1" FIELD="$2" python3 - <<'PY'
87
+ import json
88
+ import os
89
+ print(json.loads(os.environ["STATUS_DETAILS"]).get(os.environ["FIELD"], ""))
90
+ PY
91
+ }
92
+
93
+ write_result() {
94
+ local verification="$1" deployment_id="$2" deployment_state="$3" description="$4" target_url="$5" environment="$6" attempt="$7"
95
+ [ -n "$OUTPUT_FILE" ] || return 0
96
+ cat > "$OUTPUT_FILE" <<EOF
97
+ verification=${verification}
98
+ deployment_id=${deployment_id}
99
+ deployment_state=${deployment_state}
100
+ description=${description}
101
+ target_url=${target_url}
102
+ environment=${environment}
103
+ attempt=${attempt}
104
+ elapsed_seconds=$(( (ATTEMPT - 1) * POLL_SECONDS ))
105
+ EOF
106
+ }
107
+
67
108
  for ATTEMPT in $(seq 1 "$MAX_ATTEMPTS"); do
68
109
  DEPLOYMENTS_JSON=$(gh api "/repos/${REPO}/deployments?sha=${SHA}&per_page=20")
69
110
  DEPLOYMENT_ID=$(DEPLOYMENTS_JSON="$DEPLOYMENTS_JSON" select_deployment_id)
70
111
 
71
112
  if [ -z "$DEPLOYMENT_ID" ]; then
72
- echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}: no GitHub deployment found yet for ${SHA}."
113
+ echo "::notice::deployment verification attempt=${ATTEMPT}/${MAX_ATTEMPTS} sha=${SHA} deployment_id=none state=missing"
73
114
  if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
74
115
  sleep "$POLL_SECONDS"
75
116
  continue
76
117
  fi
77
- echo "::error::No GitHub deployment was published for ${SHA} after ${MAX_ATTEMPTS} attempts."
118
+ write_result "deployment_status_missing" "" "" "No GitHub deployment was published for the SHA" "" "" "$ATTEMPT"
119
+ echo "::error::deployment_status_missing: no GitHub deployment was published for ${SHA} after ${MAX_ATTEMPTS} attempts."
78
120
  exit 1
79
121
  fi
80
122
 
81
123
  STATUSES_JSON=$(gh api "/repos/${REPO}/deployments/${DEPLOYMENT_ID}/statuses?per_page=20")
82
- DEPLOY_STATE=$(STATUSES_JSON="$STATUSES_JSON" extract_status_state)
124
+ STATUS_DETAILS=$(STATUSES_JSON="$STATUSES_JSON" extract_status_details)
125
+ DEPLOY_STATE=$(json_field "$STATUS_DETAILS" state)
126
+ DESCRIPTION=$(json_field "$STATUS_DETAILS" description)
127
+ TARGET_URL=$(json_field "$STATUS_DETAILS" target_url)
128
+ ENVIRONMENT=$(json_field "$STATUS_DETAILS" environment)
129
+ echo "::notice::deployment verification attempt=${ATTEMPT}/${MAX_ATTEMPTS} sha=${SHA} deployment_id=${DEPLOYMENT_ID} environment=${ENVIRONMENT:-unknown} state=${DEPLOY_STATE:-missing} target_url=${TARGET_URL:-none} elapsed_seconds=$(( (ATTEMPT - 1) * POLL_SECONDS ))"
83
130
 
84
131
  case "$DEPLOY_STATE" in
85
132
  success)
133
+ write_result "success" "$DEPLOYMENT_ID" "$DEPLOY_STATE" "$DESCRIPTION" "$TARGET_URL" "$ENVIRONMENT" "$ATTEMPT"
86
134
  echo "GitHub deployment ${DEPLOYMENT_ID} for ${SHA} reached terminal success."
87
135
  exit 0
88
136
  ;;
89
137
  failure|error)
90
- echo "::error::GitHub deployment ${DEPLOYMENT_ID} for ${SHA} reached terminal failure (${DEPLOY_STATE})."
138
+ write_result "deployment_terminal_failure" "$DEPLOYMENT_ID" "$DEPLOY_STATE" "$DESCRIPTION" "$TARGET_URL" "$ENVIRONMENT" "$ATTEMPT"
139
+ echo "::error::deployment_terminal_failure: GitHub deployment ${DEPLOYMENT_ID} for ${SHA} reached ${DEPLOY_STATE}. ${DESCRIPTION} ${TARGET_URL}"
91
140
  exit 1
92
141
  ;;
93
142
  queued|in_progress|pending|"")
94
- echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}: deployment ${DEPLOYMENT_ID} for ${SHA} is ${DEPLOY_STATE:-pending}."
95
143
  if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
96
144
  sleep "$POLL_SECONDS"
97
145
  continue
98
146
  fi
99
- echo "::error::GitHub deployment ${DEPLOYMENT_ID} for ${SHA} did not reach a terminal success state after ${MAX_ATTEMPTS} attempts."
147
+ write_result "deployment_status_timeout" "$DEPLOYMENT_ID" "${DEPLOY_STATE:-missing}" "$DESCRIPTION" "$TARGET_URL" "$ENVIRONMENT" "$ATTEMPT"
148
+ echo "::error::deployment_status_timeout: GitHub deployment ${DEPLOYMENT_ID} for ${SHA} remained ${DEPLOY_STATE:-missing} after ${MAX_ATTEMPTS} attempts. Last target URL: ${TARGET_URL:-none}."
100
149
  exit 1
101
150
  ;;
102
151
  *)
103
- echo "::error::Deployment ${DEPLOYMENT_ID} for ${SHA} returned unexpected state '${DEPLOY_STATE}'."
152
+ write_result "deployment_status_unknown" "$DEPLOYMENT_ID" "$DEPLOY_STATE" "$DESCRIPTION" "$TARGET_URL" "$ENVIRONMENT" "$ATTEMPT"
153
+ echo "::error::deployment_status_unknown: deployment ${DEPLOYMENT_ID} for ${SHA} returned unexpected state '${DEPLOY_STATE}'. ${DESCRIPTION} ${TARGET_URL}"
104
154
  exit 1
105
155
  ;;
106
156
  esac
@@ -67,7 +67,7 @@ run_check() {
67
67
  shift 2
68
68
  (
69
69
  cd "$dir"
70
- PATH="$dir/mock-bin:$PATH" "$@" bash "$HELPER" --repo=metasession-dev/example --sha=abc123 --max-attempts=1 --poll-seconds=0
70
+ PATH="$dir/mock-bin:$PATH" "$@" bash "$HELPER" --repo=metasession-dev/example --sha=abc123 --max-attempts=1 --poll-seconds=0 --output-file="$out.result"
71
71
  ) >"$out" 2>&1
72
72
  }
73
73
 
@@ -87,6 +87,7 @@ else
87
87
  fi
88
88
  assert_exit "success deployment passes" 0 "$CODE"
89
89
  assert_grep "success message emitted" 'terminal success' "$OUT"
90
+ assert_grep "success result is structured" '^verification=success$' "$OUT.result"
90
91
  echo ""
91
92
 
92
93
  echo "--- Test 2: failure status fails ---"
@@ -99,7 +100,8 @@ else
99
100
  CODE=$?
100
101
  fi
101
102
  assert_exit "failed deployment exits 1" 1 "$CODE"
102
- assert_grep "failure message emitted" 'terminal failure' "$OUT"
103
+ assert_grep "failure message emitted" 'deployment_terminal_failure' "$OUT"
104
+ assert_grep "terminal failure result is structured" '^verification=deployment_terminal_failure$' "$OUT.result"
103
105
  echo ""
104
106
 
105
107
  echo "--- Test 3: missing deployment fails closed ---"
@@ -112,7 +114,37 @@ else
112
114
  CODE=$?
113
115
  fi
114
116
  assert_exit "missing deployment exits 1" 1 "$CODE"
115
- assert_grep "missing deployment message emitted" 'No GitHub deployment was published' "$OUT"
117
+ assert_grep "missing deployment message emitted" 'deployment_status_missing' "$OUT"
118
+ assert_grep "missing deployment result is structured" '^verification=deployment_status_missing$' "$OUT.result"
119
+ echo ""
120
+
121
+ echo "--- Test 4: prolonged in-progress status reports timeout ---"
122
+ OUT="$WORK/test4.out"
123
+ if run_check "$WORK" "$OUT" env \
124
+ MOCK_DEPLOYMENTS_JSON='[{"id":5453707224,"environment":"production"}]' \
125
+ MOCK_STATUSES_JSON='[{"state":"in_progress","description":"provider still building","environment_url":"https://example.test"}]'; then
126
+ CODE=0
127
+ else
128
+ CODE=$?
129
+ fi
130
+ assert_exit "in-progress deployment fails closed" 1 "$CODE"
131
+ assert_grep "timeout classification emitted" 'deployment_status_timeout' "$OUT"
132
+ assert_grep "timeout result is structured" '^verification=deployment_status_timeout$' "$OUT.result"
133
+ assert_grep "timeout retains target URL" '^target_url=https://example.test$' "$OUT.result"
134
+ echo ""
135
+
136
+ echo "--- Test 5: missing status reports timeout ---"
137
+ OUT="$WORK/test5.out"
138
+ if run_check "$WORK" "$OUT" env \
139
+ MOCK_DEPLOYMENTS_JSON='[{"id":5453707224,"environment":"production"}]' \
140
+ MOCK_STATUSES_JSON='[]'; then
141
+ CODE=0
142
+ else
143
+ CODE=$?
144
+ fi
145
+ assert_exit "missing status fails closed" 1 "$CODE"
146
+ assert_grep "missing status timeout classification emitted" 'deployment_status_timeout' "$OUT"
147
+ assert_grep "missing status result is structured" '^deployment_state=missing$' "$OUT.result"
116
148
  echo ""
117
149
 
118
150
  echo "=== check-host-deployment.test.sh: $PASS passed, $FAIL failed ==="
@@ -136,16 +136,17 @@ echo ""
136
136
 
137
137
  echo "--- Test 5: bare-date releases require an explicit standalone declaration ---"
138
138
  make_fixture "$WORK/test5" "chore: standalone housekeeping release"
139
- cat > "$WORK/test5/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-v2026.07.18.json" <<'EOF'
139
+ BARE_DATE_VERSION="v$(date -u +%Y.%m.%d)"
140
+ cat > "$WORK/test5/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
140
141
  {
141
142
  "schemaVersion": 1,
142
- "version": "v2026.07.18",
143
+ "version": "${BARE_DATE_VERSION}",
143
144
  "releaseMode": "standalone_housekeeping",
144
145
  "reason": "Urgent operational change cannot wait for the next tracked requirement release."
145
146
  }
146
147
  EOF
147
148
  OUT="$WORK/test5.out"
148
- if run_check "$WORK/test5" "$OUT" env PR_TITLE="Standalone housekeeping promotion: v2026.07.18" PR_BODY=$'## Release\n- Release: v2026.07.18\n'; then
149
+ if run_check "$WORK/test5" "$OUT" env PR_TITLE="Standalone housekeeping promotion: ${BARE_DATE_VERSION}" PR_BODY=$"## Release\n- Release: ${BARE_DATE_VERSION}\n"; then
149
150
  CODE=0
150
151
  else
151
152
  CODE=$?
@@ -156,16 +157,16 @@ echo ""
156
157
 
157
158
  echo "--- Test 6: bare-date releases without the exception marker fail ---"
158
159
  make_fixture "$WORK/test6" "chore: standalone housekeeping release"
159
- cat > "$WORK/test6/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-v2026.07.18.json" <<'EOF'
160
+ cat > "$WORK/test6/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
160
161
  {
161
162
  "schemaVersion": 1,
162
- "version": "v2026.07.18",
163
+ "version": "${BARE_DATE_VERSION}",
163
164
  "releaseMode": "standalone_housekeeping",
164
165
  "reason": "Urgent operational change cannot wait for the next tracked requirement release."
165
166
  }
166
167
  EOF
167
168
  OUT="$WORK/test6.out"
168
- if run_check "$WORK/test6" "$OUT" env PR_TITLE="Release: v2026.07.18" PR_BODY=$'## Release\n- Release: v2026.07.18\n'; then
169
+ if run_check "$WORK/test6" "$OUT" env PR_TITLE="Release: ${BARE_DATE_VERSION}" PR_BODY=$"## Release\n- Release: ${BARE_DATE_VERSION}\n"; then
169
170
  CODE=0
170
171
  else
171
172
  CODE=$?
@@ -24,13 +24,11 @@
24
24
  # -> empty/skip
25
25
  # 5. Fallback: bare date -> v2026.05.17
26
26
  #
27
- # Step 4 (DevAudit-Installer#92) handles `chore:` / `docs:` / `ci:`
28
- # commits (e.g. a `devaudit update` sync) landing on the integration
29
- # branch between feature merge and release-PR open. Such a commit has
30
- # no REQ tag in its message steps 1-3 fall through. The release
31
- # ticket on disk is a stronger explicit-operator-state signal than the
32
- # bare date — when exactly one ticket is open, attribute to it.
33
- # Multiple open tickets stays ambiguous → bare-date fallback.
27
+ # A pending ticket or RTM row is not commit ownership. In particular, an
28
+ # independent main hotfix must not become part of an active UAT REQ merely
29
+ # because its mandatory back-merge lands while that REQ is the only pending
30
+ # ticket. The optional fallback below is disabled by default and may be used
31
+ # only for an explicitly reviewed integration exception.
34
32
  #
35
33
  # Note (DevAudit-Installer#220): `devaudit update` syncs now include
36
34
  # `[skip ci]` in their commit message, so they no longer trigger CI
@@ -91,7 +89,7 @@ fi
91
89
  # operator's explicit state says THIS is the in-flight release. Use it.
92
90
  # Zero or multiple → ambiguous, fall through to the bare date.
93
91
  # DevAudit-Installer#92.
94
- if [ -d compliance/pending-releases ]; then
92
+ if [ "${DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK:-0}" = "1" ] && [ -d compliance/pending-releases ]; then
95
93
  # NUL-delimited count so filenames with spaces don't trip us up.
96
94
  TICKET_COUNT=$(find compliance/pending-releases -maxdepth 1 -type f \
97
95
  -name 'RELEASE-TICKET-REQ-*.md' -print0 2>/dev/null \
@@ -113,7 +111,7 @@ fi
113
111
  # multiple IN PROGRESS rows → ambiguous, fall through.
114
112
  # DevAudit-Installer#95.
115
113
  RTM_PATH="${RTM_PATH:-compliance/RTM.md}"
116
- if [ -f "$RTM_PATH" ]; then
114
+ if [ "${DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK:-0}" = "1" ] && [ -f "$RTM_PATH" ]; then
117
115
  # Match REQ rows whose status column starts with `IN PROGRESS`.
118
116
  # `\|[[:space:]]+IN PROGRESS` requires a pipe followed by whitespace,
119
117
  # so legend rows (`| \`IN PROGRESS\``) and prose mentions don't match.
@@ -111,12 +111,9 @@ make_fixture "$WORK/c8" "Merge pull request #7 from metasession-dev/feat/req-002
111
111
  chore(deps): [REQ-002] dependency hardening — close R-001"
112
112
  assert_eq "merge-commit body [REQ-002] -> REQ-002" "REQ-002" "$(run_helper)"
113
113
 
114
- # Case 9 (DevAudit-Installer#92): a chore: sync commit has no REQ tag in
115
- # its message but a pending release ticket exists on disk. Must attribute
116
- # to the REQ from that ticket instead of falling through to the bare date.
117
- # Regression for REQ-051 / REQ-052 gate evidence landing on phantom date
118
- # releases when a `chore: devaudit update to 0.1.x` commit landed between
119
- # the feature merge and the release-PR open on wawagardenbar-app.
114
+ # Case 9: a pending ticket is not ownership of an unrelated commit. The
115
+ # default is a historical bare-date record; explicit operator exceptions may
116
+ # opt in with DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1.
120
117
  make_fixture "$WORK/c9" "chore: devaudit update to 0.1.27"
121
118
  mkdir -p compliance/pending-releases
122
119
  cat > compliance/pending-releases/RELEASE-TICKET-REQ-051.md <<'TICKET'
@@ -124,7 +121,9 @@ cat > compliance/pending-releases/RELEASE-TICKET-REQ-051.md <<'TICKET'
124
121
 
125
122
  **Status:** TESTED - PENDING SIGN-OFF
126
123
  TICKET
127
- assert_eq "chore: sync + single pending ticket REQ-051 -> REQ-051" "REQ-051" "$(run_helper)"
124
+ assert_eq "chore: sync + single pending ticket -> bare date $TODAY" "$TODAY" "$(run_helper)"
125
+ GOT=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
126
+ assert_eq "explicit pending-ticket exception -> REQ-051" "REQ-051" "$GOT"
128
127
 
129
128
  # Case 10: a chore: sync commit with MULTIPLE pending tickets — ambiguous,
130
129
  # stays at the bare-date fallback (don't guess between them).
@@ -154,11 +153,7 @@ cat > compliance/pending-releases/RELEASE-TICKET-REQ-051.md <<'TICKET'
154
153
  TICKET
155
154
  assert_eq "subject [REQ-099] beats pending REQ-051 -> REQ-099" "REQ-099" "$(run_helper)"
156
155
 
157
- # Case 13 (DevAudit-Installer#95): step-4-bis. No subject/body tag,
158
- # no pending ticket, but RTM.md has exactly one IN PROGRESS row.
159
- # Attribute to that REQ. Tests the zero-ceremony fallback that
160
- # survives chore/docs/ci sync commits when no operator state file
161
- # has been dropped.
156
+ # Case 13: an RTM IN PROGRESS row is likewise not automatic commit ownership.
162
157
  make_fixture "$WORK/c13" "chore: devaudit update to 0.1.29"
163
158
  mkdir -p compliance
164
159
  cat > compliance/RTM.md <<'RTM'
@@ -168,7 +163,9 @@ cat > compliance/RTM.md <<'RTM'
168
163
  | REQ-100 | #10 | LOW | compliance/evidence/REQ-100/ | APPROVED - DEPLOYED | dev | 2026-05-30 |
169
164
  | REQ-101 | #11 | MED | compliance/evidence/REQ-101/ | IN PROGRESS | dev | 2026-06-01 |
170
165
  RTM
171
- assert_eq "RTM single IN PROGRESS row -> REQ-101" "REQ-101" "$(run_helper)"
166
+ assert_eq "RTM single IN PROGRESS row -> bare date $TODAY" "$TODAY" "$(run_helper)"
167
+ GOT=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
168
+ assert_eq "explicit RTM fallback exception -> REQ-101" "REQ-101" "$GOT"
172
169
 
173
170
  # Case 14: step-4-bis ambiguity guard. Two IN PROGRESS rows → falls
174
171
  # through to the bare date rather than guessing.
@@ -210,7 +207,8 @@ cat > compliance/RTM.md <<'RTM'
210
207
  | ------- | ----- | ----------- | ---------------------------- | --------------------------------------------------------------------- | ---------- | ---------- |
211
208
  | REQ-056 | #117 | MEDIUM-HIGH | compliance/evidence/REQ-056/ | IN PROGRESS (WhatsApp inbound-message router; many details follow...) | ostendo-io | 2026-06-01 |
212
209
  RTM
213
- assert_eq "RTM long parenthetical status -> REQ-056" "REQ-056" "$(run_helper)"
210
+ GOT=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
211
+ assert_eq "explicit RTM long-status fallback -> REQ-056" "REQ-056" "$GOT"
214
212
 
215
213
  # Case 17: step-4-bis must NOT win over a pending ticket on disk.
216
214
  # Step 4 returns first.
@@ -224,7 +222,8 @@ cat > compliance/RTM.md <<'RTM'
224
222
  | ------- | ----------- |
225
223
  | REQ-302 | IN PROGRESS |
226
224
  RTM
227
- assert_eq "pending ticket REQ-301 beats RTM IN PROGRESS REQ-302" "REQ-301" "$(run_helper)"
225
+ GOT=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
226
+ assert_eq "explicit pending ticket beats RTM fallback" "REQ-301" "$GOT"
228
227
 
229
228
  # Case 18: step-4-bis respects RTM_PATH env override.
230
229
  make_fixture "$WORK/c18" "chore: devaudit update to 0.1.29"
@@ -234,8 +233,8 @@ cat > docs/custom-RTM.md <<'RTM'
234
233
  | ------- | ----------- |
235
234
  | REQ-400 | IN PROGRESS |
236
235
  RTM
237
- GOT=$(RTM_PATH=docs/custom-RTM.md run_helper)
238
- assert_eq "RTM_PATH=docs/custom-RTM.md -> REQ-400" "REQ-400" "$GOT"
236
+ GOT=$(RTM_PATH=docs/custom-RTM.md DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
237
+ assert_eq "explicit RTM_PATH fallback exception -> REQ-400" "REQ-400" "$GOT"
239
238
 
240
239
  # Case 19: step-4-bis with escaped pipes (\|) in the Status column.
241
240
  # The Status cell contains literal pipe characters escaped as \| (markdown
@@ -251,7 +250,8 @@ cat > compliance/RTM.md <<'RTM'
251
250
  | REQ-056 | #117 | MEDIUM-HIGH | compliance/evidence/REQ-056/ | IN PROGRESS (regex: /^\s*(stop\|unsubscribe\|opt[-\s]?out)\s*$/i) | ostendo-io | 2026-06-01 |
252
251
  | REQ-064 | #121 | MEDIUM | compliance/evidence/REQ-064/ | RELEASED (enum: open\|in_progress\|awaiting_customer\|resolved\|closed) | dev | 2026-06-02 |
253
252
  RTM
254
- assert_eq "RTM escaped pipes -> REQ-056 (single IN PROGRESS)" "REQ-056" "$(run_helper)"
253
+ GOT=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK=1 run_helper)
254
+ assert_eq "explicit RTM escaped-pipe fallback -> REQ-056" "REQ-056" "$GOT"
255
255
 
256
256
  # Case 20: step-4-bis with escaped pipes — ambiguity guard still works.
257
257
  # Two IN PROGRESS rows, both with escaped pipes in status → falls through
@@ -250,8 +250,29 @@ for version in "${EXPLICIT_PREDECESSORS[@]}"; do
250
250
  PREDECESSOR_LINES+=("- \`${version}\` (${role}/${relationship}) — ${title:-Untitled release ticket}")
251
251
  done
252
252
 
253
- COMMITS="$(git log "$SINCE_REF"..HEAD --format='%h%x09%s' 2>/dev/null || true)"
254
- BUNDLED="$(printf '%s\n' "$COMMITS" | grep -E "$HOUSEKEEPING_TYPES" || true)"
253
+ # A tracked release only absorbs genuinely REQ-free housekeeping performed
254
+ # during its own implementation window. Do not recast earlier history, or a
255
+ # conventional commit explicitly owned by any REQ, as generic bundle work.
256
+ SCAN_FROM="$SINCE_REF"
257
+ if [[ "$VERSION" =~ ^REQ-[0-9]+$ ]]; then
258
+ FIRST_REQ_SHA="$(git log --reverse --format='%H' --grep="\\[${VERSION}\\]\\|Ref: ${VERSION}" 2>/dev/null | head -1 || true)"
259
+ if [ -n "$FIRST_REQ_SHA" ] && git rev-parse --verify "${FIRST_REQ_SHA}^" >/dev/null 2>&1; then
260
+ SCAN_FROM="${FIRST_REQ_SHA}^"
261
+ fi
262
+ fi
263
+ COMMITS="$(git log "$SCAN_FROM"..HEAD --format='%h%x09%s' 2>/dev/null || true)"
264
+ BUNDLED=""
265
+ while IFS=$'\t' read -r sha subject; do
266
+ [ -n "$sha" ] || continue
267
+ if ! printf '%s\t%s\n' "$sha" "$subject" | grep -Eq "$HOUSEKEEPING_TYPES"; then
268
+ continue
269
+ fi
270
+ if git log -1 --format='%s%n%b' "$sha" | grep -Eq '\[REQ-[0-9]+\]|Ref:[[:space:]]*REQ-[0-9]+'; then
271
+ continue
272
+ fi
273
+ BUNDLED+="${sha}"$'\t'"${subject}"$'\n'
274
+ done <<<"$COMMITS"
275
+ BUNDLED="${BUNDLED%$'\n'}"
255
276
  NON_RELEASE_ITEMS='[]'
256
277
  NON_RELEASE_LINES=()
257
278
  if [ -n "$BUNDLED" ]; then
@@ -324,7 +345,7 @@ echo "- **Why bundled here:** Explicit predecessor release tickets and non-relea
324
345
  echo "- **Evidence impact:** Evidence ownership remains on the source releases; the bundle manifest provides lineage and inherited visibility only."
325
346
  echo "- **Reviewer impact:** Approval scope includes the core tracked release plus the explicit predecessor releases and non-release work listed below."
326
347
  echo "- **Security / risk impact:** No additional security/risk impact identified automatically; reviewer must confirm in the canonical release artifacts."
327
- echo "- **Reference:** commit range \`${SINCE_REF}..HEAD\`"
348
+ echo "- **Reference:** commit range \`${SCAN_FROM}..HEAD\`"
328
349
  echo ""
329
350
 
330
351
  echo "### Explicit Constituent Releases"
@@ -109,25 +109,29 @@ assert_file_exists() {
109
109
  fi
110
110
  }
111
111
 
112
- # Test 1: housekeeping commits appear in markdown + JSON.
113
- echo "Test 1: housekeeping commits render as non-release work"
112
+ # Test 1: only REQ-free housekeeping inside the tracked REQ window is bundled.
113
+ echo "Test 1: release ownership excludes tagged and historical work"
114
114
  DIR1="$TMPDIR_BASE/test1"
115
115
  make_fixture "$DIR1"
116
116
  add_commit "chore: sync DevAudit templates from v0.1.69 to v0.1.70 [skip ci]"
117
117
  add_commit "docs: update API reference for /bookings endpoint"
118
118
  add_commit "feat: add booking widget [REQ-042]"
119
+ add_commit "test: verify booking widget [REQ-042]"
119
120
  add_commit "chore(deps): bump eslint 9.0.5 to 9.0.6"
121
+ add_commit "docs: document booking widget\n\nRef: REQ-042"
120
122
  add_commit "fix: resolve null pointer in booking service [REQ-042]"
121
123
  SINCE=$(git rev-list --max-parents=0 HEAD)
122
124
  JSON_OUT="$DIR1/bundle.json"
123
125
  OUTPUT=$(bash "$HELPER" "$SINCE" "REQ-042" --json-out "$JSON_OUT" 2>&1)
124
- assert_contains "markdown includes chore commit" "chore: sync DevAudit templates" "$OUTPUT"
125
- assert_contains "markdown includes docs commit" "docs: update API reference" "$OUTPUT"
126
126
  assert_contains "markdown includes scoped chore commit" "chore(deps): bump eslint" "$OUTPUT"
127
+ assert_not_contains "markdown excludes historical chore commit" "chore: sync DevAudit templates" "$OUTPUT"
128
+ assert_not_contains "markdown excludes historical docs commit" "docs: update API reference" "$OUTPUT"
129
+ assert_not_contains "markdown excludes tagged test commit" "test: verify booking widget" "$OUTPUT"
130
+ assert_not_contains "markdown excludes Ref-tagged docs commit" "docs: document booking widget" "$OUTPUT"
127
131
  assert_not_contains "markdown excludes feat commit" "feat: add booking widget" "$OUTPUT"
128
132
  assert_not_contains "markdown excludes fix commit" "fix: resolve null pointer" "$OUTPUT"
129
133
  assert_file_exists "json manifest emitted" "$JSON_OUT"
130
- assert_eq "non-release work count" "3" "$(jq -r '.nonReleaseWorkItems | length' "$JSON_OUT")"
134
+ assert_eq "non-release work count" "1" "$(jq -r '.nonReleaseWorkItems | length' "$JSON_OUT")"
131
135
  assert_eq "member count with no tickets" "0" "$(jq -r '.members | length' "$JSON_OUT")"
132
136
  assert_eq "first housekeeping kind" "housekeeping_commit" "$(jq -r '.nonReleaseWorkItems[0].kind' "$JSON_OUT")"
133
137
  echo
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Reconcile a verified Railway recovery deployment to its GitHub deployment.
3
+ set -euo pipefail
4
+
5
+ REPO=""; SHA=""; GITHUB_DEPLOYMENT_ID=""; RAILWAY_DEPLOYMENT_ID=""
6
+ RAILWAY_PROJECT=""; RAILWAY_ENVIRONMENT="production"; RAILWAY_SERVICE=""; HEALTH_URL=""
7
+ for arg in "$@"; do
8
+ case "$arg" in
9
+ --repo=*) REPO="${arg#*=}" ;; --sha=*) SHA="${arg#*=}" ;;
10
+ --github-deployment-id=*) GITHUB_DEPLOYMENT_ID="${arg#*=}" ;;
11
+ --railway-deployment-id=*) RAILWAY_DEPLOYMENT_ID="${arg#*=}" ;;
12
+ --railway-project=*) RAILWAY_PROJECT="${arg#*=}" ;;
13
+ --railway-environment=*) RAILWAY_ENVIRONMENT="${arg#*=}" ;;
14
+ --railway-service=*) RAILWAY_SERVICE="${arg#*=}" ;;
15
+ --health-url=*) HEALTH_URL="${arg#*=}" ;;
16
+ *) echo "Unknown argument: $arg" >&2; exit 2 ;;
17
+ esac
18
+ done
19
+ [ -n "$REPO" ] && [ -n "$SHA" ] && [ -n "$GITHUB_DEPLOYMENT_ID" ] && [ -n "$RAILWAY_DEPLOYMENT_ID" ] && [ -n "$RAILWAY_PROJECT" ] && [ -n "$RAILWAY_SERVICE" ] && [ -n "$HEALTH_URL" ] || { echo "Missing required reconciliation arguments" >&2; exit 2; }
20
+
21
+ DEPLOYMENTS=$(railway deployment list --json --project "$RAILWAY_PROJECT" --environment "$RAILWAY_ENVIRONMENT" --service "$RAILWAY_SERVICE" --limit 100)
22
+ MATCH=$(jq -cer --arg id "$RAILWAY_DEPLOYMENT_ID" '.[] | select(.id == $id)' <<<"$DEPLOYMENTS") || { echo "::error::Railway deployment ${RAILWAY_DEPLOYMENT_ID} was not found."; exit 1; }
23
+ STATUS=$(jq -r '.status // empty' <<<"$MATCH")
24
+ PROVIDER_SHA=$(jq -r '.meta.commitHash // empty' <<<"$MATCH")
25
+ BRANCH=$(jq -r '.meta.branch // empty' <<<"$MATCH")
26
+ PROVIDER_REPO=$(jq -r '.meta.repo // empty' <<<"$MATCH")
27
+ [ "$STATUS" = "SUCCESS" ] || { echo "::error::Railway deployment is not terminal-successful: ${STATUS:-missing}"; exit 1; }
28
+ [ "$PROVIDER_SHA" = "$SHA" ] && [ "$BRANCH" = "main" ] && [ "$PROVIDER_REPO" = "$REPO" ] || { echo "::error::Railway provenance mismatch: repo=$PROVIDER_REPO branch=$BRANCH sha=$PROVIDER_SHA"; exit 1; }
29
+ HTTP=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 15 "$HEALTH_URL" || echo 000)
30
+ [[ "$HTTP" =~ ^[23][0-9][0-9]$ ]] || { echo "::error::Health probe failed for ${HEALTH_URL}: HTTP ${HTTP}"; exit 1; }
31
+ DESCRIPTION="manual_reconciliation; railway_deployment=${RAILWAY_DEPLOYMENT_ID}; sha=${SHA}; health_http=${HTTP}; verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
32
+ EXISTING=$(gh api "/repos/${REPO}/deployments/${GITHUB_DEPLOYMENT_ID}/statuses?per_page=20" --jq '[.[] | select(.state == "success" and (.description // "" | contains("railway_deployment='"$RAILWAY_DEPLOYMENT_ID"'")))] | length')
33
+ if [ "$EXISTING" = "0" ]; then
34
+ gh api --method POST "/repos/${REPO}/deployments/${GITHUB_DEPLOYMENT_ID}/statuses" -f state=success -f environment=production -f description="$DESCRIPTION" -f environment_url="$HEALTH_URL" >/dev/null
35
+ fi
36
+ jq -n --arg provenance manual_reconciliation --arg provider railway --arg providerDeploymentId "$RAILWAY_DEPLOYMENT_ID" --arg sha "$SHA" --arg healthUrl "$HEALTH_URL" --arg healthHttp "$HTTP" --arg actor "${GITHUB_ACTOR:-operator}" --arg verifiedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '{provenance:$provenance,provider:$provider,providerDeploymentId:$providerDeploymentId,sha:$sha,healthUrl:$healthUrl,healthHttp:($healthHttp|tonumber),actor:$actor,verifiedAt:$verifiedAt}' > deployment-reconciliation.json
37
+ echo "Verified Railway deployment ${RAILWAY_DEPLOYMENT_ID}; GitHub deployment ${GITHUB_DEPLOYMENT_ID} reconciled."
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT="$(cd "$(dirname "$0")" && pwd)"; HELPER="$ROOT/reconcile-railway-deployment.sh"
4
+ WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT; PASS=0; FAIL=0
5
+ ok(){ echo " ok $1"; PASS=$((PASS+1)); }; no(){ echo " FAIL $1"; FAIL=$((FAIL+1)); }
6
+ mock() { mkdir -p "$1/bin"; cat >"$1/bin/railway" <<'EOF'
7
+ #!/usr/bin/env bash
8
+ printf '%s' "$MOCK_RAILWAY_JSON"
9
+ EOF
10
+ cat >"$1/bin/curl" <<'EOF'
11
+ #!/usr/bin/env bash
12
+ printf '%s' "${MOCK_HTTP:-200}"
13
+ EOF
14
+ cat >"$1/bin/gh" <<'EOF'
15
+ #!/usr/bin/env bash
16
+ if [[ "$*" == *"--jq"* ]]; then printf '%s' "${MOCK_EXISTING:-[]}"; else echo "$*" >> "$MOCK_GH_LOG"; fi
17
+ EOF
18
+ chmod +x "$1/bin/"*; }
19
+ run(){ local d="$1"; shift; (cd "$d"; PATH="$d/bin:$PATH" MOCK_GH_LOG="$d/gh.log" "$@" bash "$HELPER" --repo=org/app --sha=abc --github-deployment-id=42 --railway-deployment-id=r1 --railway-project=p --railway-service=s --health-url=https://health.test); }
20
+ mock "$WORK"
21
+ GOOD='[{"id":"r1","status":"SUCCESS","meta":{"repo":"org/app","branch":"main","commitHash":"abc"}}]'
22
+ if run "$WORK" env MOCK_RAILWAY_JSON="$GOOD"; then ok valid; else no valid; fi
23
+ grep -q '"provenance": "manual_reconciliation"' "$WORK/deployment-reconciliation.json" && ok provenance_evidence || no provenance_evidence
24
+ if run "$WORK" env MOCK_RAILWAY_JSON='[{"id":"r1","status":"SUCCESS","meta":{"repo":"org/app","branch":"main","commitHash":"wrong"}}]'; then no sha_mismatch; else ok sha_mismatch; fi
25
+ if run "$WORK" env MOCK_RAILWAY_JSON='[{"id":"r1","status":"FAILED","meta":{"repo":"org/app","branch":"main","commitHash":"abc"}}]'; then no provider_failure; else ok provider_failure; fi
26
+ if run "$WORK" env MOCK_RAILWAY_JSON="$GOOD" MOCK_HTTP=500; then no health_failure; else ok health_failure; fi
27
+ rm -f "$WORK/gh.log"
28
+ if run "$WORK" env MOCK_RAILWAY_JSON="$GOOD" MOCK_EXISTING='[{"state":"success","description":"railway_deployment=r1"}]'; then ok idempotent; else no idempotent; fi
29
+ [ ! -f "$WORK/gh.log" ] && ok no_duplicate_post || no no_duplicate_post
30
+ echo "$PASS passed, $FAIL failed"; [ "$FAIL" -eq 0 ]