@metasession.co/devaudit-cli 0.3.17 → 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/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/sdlc/files/_common/scripts/check-host-deployment.sh +58 -8
- package/sdlc/files/_common/scripts/check-host-deployment.test.sh +35 -3
- package/sdlc/files/_common/scripts/check-release-pr-scope.test.sh +7 -6
- package/sdlc/files/_common/scripts/derive-release-version.sh +7 -9
- package/sdlc/files/_common/scripts/derive-release-version.test.sh +18 -18
- package/sdlc/files/_common/scripts/generate-bundled-changes.sh +24 -3
- package/sdlc/files/_common/scripts/generate-bundled-changes.test.sh +9 -5
- package/sdlc/files/_common/scripts/reconcile-railway-deployment.sh +37 -0
- package/sdlc/files/_common/scripts/reconcile-railway-deployment.test.sh +30 -0
- package/sdlc/files/_common/skills/e2e-test-engineer/references/e2e-regression-3-tier.yml +59 -10
- package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +3 -2
- package/sdlc/files/ci/ci.yml.template +38 -8
- package/sdlc/files/ci/compliance-evidence.yml.template +38 -3
- package/sdlc/files/ci/post-deploy-prod.yml.template +60 -19
- package/sdlc/files/ci/reconcile-deployment.yml.template +59 -0
- package/sdlc/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metasession.co/devaudit-cli",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" '
|
|
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" '
|
|
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
|
-
|
|
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": "
|
|
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:
|
|
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
|
|
160
|
+
cat > "$WORK/test6/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
|
|
160
161
|
{
|
|
161
162
|
"schemaVersion": 1,
|
|
162
|
-
"version": "
|
|
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:
|
|
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
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
|
115
|
-
#
|
|
116
|
-
#
|
|
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
|
|
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
|
|
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 ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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 \`${
|
|
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
|
|
113
|
-
echo "Test 1:
|
|
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" "
|
|
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 ]
|