@metasession.co/devaudit-cli 0.3.17 → 0.3.19

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 (45) hide show
  1. package/dist/index.js +36 -34
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/scripts/upload-evidence.sh +23 -19
  5. package/sdlc/files/_common/governance/incident-report.md.template +6 -1
  6. package/sdlc/files/_common/governance/nil-incident-report.md.template +5 -2
  7. package/sdlc/files/_common/scripts/check-host-deployment.sh +58 -8
  8. package/sdlc/files/_common/scripts/check-host-deployment.test.sh +35 -3
  9. package/sdlc/files/_common/scripts/check-release-pr-scope.sh +8 -1
  10. package/sdlc/files/_common/scripts/check-release-pr-scope.test.sh +61 -12
  11. package/sdlc/files/_common/scripts/check-self-hosted-runner.sh +103 -0
  12. package/sdlc/files/_common/scripts/check-self-hosted-runner.test.sh +74 -0
  13. package/sdlc/files/_common/scripts/derive-release-version.sh +7 -9
  14. package/sdlc/files/_common/scripts/derive-release-version.test.sh +18 -18
  15. package/sdlc/files/_common/scripts/generate-bundled-changes.sh +24 -3
  16. package/sdlc/files/_common/scripts/generate-bundled-changes.test.sh +9 -5
  17. package/sdlc/files/_common/scripts/reconcile-railway-deployment.sh +37 -0
  18. package/sdlc/files/_common/scripts/reconcile-railway-deployment.test.sh +30 -0
  19. package/sdlc/files/_common/scripts/record-uat-execution.sh +195 -0
  20. package/sdlc/files/_common/scripts/record-uat-execution.test.sh +144 -0
  21. package/sdlc/files/_common/scripts/render-test-executions.sh +146 -0
  22. package/sdlc/files/_common/scripts/{render-test-cycles.test.sh → render-test-executions.test.sh} +12 -18
  23. package/sdlc/files/_common/scripts/{report-test-cycle.sh → report-test-execution.sh} +66 -74
  24. package/sdlc/files/_common/scripts/report-test-execution.test.sh +177 -0
  25. package/sdlc/files/_common/scripts/submit-for-uat-review.sh +21 -0
  26. package/sdlc/files/_common/scripts/upload-evidence.sh +23 -19
  27. package/sdlc/files/_common/scripts/validate-commits.sh +6 -4
  28. package/sdlc/files/_common/scripts/validate-commits.test.sh +15 -0
  29. package/sdlc/files/_common/skills/e2e-test-engineer/SKILL.md +9 -7
  30. package/sdlc/files/_common/skills/e2e-test-engineer/references/e2e-regression-3-tier.yml +62 -10
  31. package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +19 -16
  32. package/sdlc/files/ci/ci.yml.template +86 -46
  33. package/sdlc/files/ci/compliance-evidence.yml.template +173 -49
  34. package/sdlc/files/ci/feature-e2e.yml.template +17 -17
  35. package/sdlc/files/ci/incident-export.yml.template +18 -4
  36. package/sdlc/files/ci/post-deploy-prod.yml.template +92 -48
  37. package/sdlc/files/ci/python/ci.yml.template +14 -14
  38. package/sdlc/files/ci/quality-gates-provenance.yml.template +3 -0
  39. package/sdlc/files/ci/reconcile-deployment.yml.template +59 -0
  40. package/sdlc/package.json +1 -1
  41. package/sdlc/src/blueprints/3-compile-evidence.raw.md +10 -10
  42. package/sdlc/src/blueprints/4-submit-for-review.raw.md +1 -1
  43. package/sdlc/src/blueprints/5-deploy-main.raw.md +1 -1
  44. package/sdlc/src/blueprints/implementing-an-sdlc-issue.raw.md +3 -3
  45. package/sdlc/files/_common/scripts/render-test-cycles.sh +0 -227
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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.17",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.19",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -42,19 +42,19 @@
42
42
  # 4 submit-for-review, 5 deploy. Forwarded as
43
43
  # `sdlcStage`; unknown to older portals (ignored
44
44
  # server-side, no error).
45
- # --test-cycle <id> Test cycle identifier (typically the CI run
46
- # ID). Forwarded as `testCycleId`; lets the
47
- # portal group evidence by test cycle per
48
- # ISO/IEC/IEEE 29119-3. Optional — older
49
- # portals ignore the field (no error).
45
+ # --test-execution <id> Test execution identifier (typically the CI run
46
+ # ID). Forwarded to the portal transport as
47
+ # `testCycleId` until the upload API field is
48
+ # renamed.
50
49
  # --evidence-scope <scope> Evidence ownership scope: release | stage |
51
- # cycle | approval. Forwarded as
52
- # `evidenceScope`; optional and ignored by
53
- # older portals.
54
- # --test-cycle-record-id <id> First-class portal cycle UUID. Requires
55
- # `--evidence-scope cycle`. Forwarded as
56
- # `testCycleRecordId`; preserved alongside
57
- # legacy `--test-cycle` during dual-write.
50
+ # execution | approval. `execution` is mapped
51
+ # to the portal transport value `cycle` until
52
+ # the upload API field is renamed.
53
+ # --test-execution-record-id <id>
54
+ # First-class portal test execution UUID.
55
+ # Requires `--evidence-scope execution`.
56
+ # Forwarded to the portal transport as
57
+ # `testCycleRecordId`.
58
58
  #
59
59
  # Required environment variables:
60
60
  # DEVAUDIT_BASE_URL e.g. https://meta-comply-production.up.railway.app
@@ -136,9 +136,9 @@ while [ "$#" -gt 0 ]; do
136
136
  # DevAudit-Installer#96.
137
137
  --gate-status) GATE_STATUS="$2"; shift 2 ;;
138
138
  --sdlc-stage) SDLC_STAGE="$2"; shift 2 ;;
139
- --test-cycle) TEST_CYCLE="$2"; shift 2 ;;
139
+ --test-execution) TEST_CYCLE="$2"; shift 2 ;;
140
140
  --evidence-scope) EVIDENCE_SCOPE="$2"; shift 2 ;;
141
- --test-cycle-record-id) TEST_CYCLE_RECORD_ID="$2"; shift 2 ;;
141
+ --test-execution-record-id) TEST_CYCLE_RECORD_ID="$2"; shift 2 ;;
142
142
  # --meta-key key=value (repeatable). Merged into the metadata JSON
143
143
  # before posting. Validates the `key=value` shape; rejects bare
144
144
  # keys without `=`.
@@ -166,14 +166,18 @@ if [ -n "$SDLC_STAGE" ] && ! [[ "$SDLC_STAGE" =~ ^[1-5]$ ]]; then
166
166
  echo "Error: --sdlc-stage must be an integer 1-5 (got: $SDLC_STAGE)"
167
167
  exit 1
168
168
  fi
169
- if [ -n "$EVIDENCE_SCOPE" ] && ! [[ "$EVIDENCE_SCOPE" =~ ^(release|stage|cycle|approval)$ ]]; then
170
- echo "Error: --evidence-scope must be one of: release, stage, cycle, approval"
169
+ if [ -n "$EVIDENCE_SCOPE" ] && ! [[ "$EVIDENCE_SCOPE" =~ ^(release|stage|execution|approval)$ ]]; then
170
+ echo "Error: --evidence-scope must be one of: release, stage, execution, approval"
171
171
  exit 1
172
172
  fi
173
- if [ -n "$TEST_CYCLE_RECORD_ID" ] && [ "$EVIDENCE_SCOPE" != "cycle" ]; then
174
- echo "Error: --test-cycle-record-id requires --evidence-scope cycle"
173
+ if [ -n "$TEST_CYCLE_RECORD_ID" ] && [ "$EVIDENCE_SCOPE" != "execution" ]; then
174
+ echo "Error: --test-execution-record-id requires --evidence-scope execution"
175
175
  exit 1
176
176
  fi
177
+ TRANSPORT_EVIDENCE_SCOPE="$EVIDENCE_SCOPE"
178
+ if [ "$TRANSPORT_EVIDENCE_SCOPE" = "execution" ]; then
179
+ TRANSPORT_EVIDENCE_SCOPE="cycle"
180
+ fi
177
181
 
178
182
  if [ -z "${DEVAUDIT_BASE_URL:-}" ]; then
179
183
  echo "Error: DEVAUDIT_BASE_URL environment variable is required"
@@ -553,7 +557,7 @@ for FILE in "${FILES[@]}"; do
553
557
  [ -n "$GATE_STATUS" ] && CURL_ARGS+=(-F "gateStatus=${GATE_STATUS}")
554
558
  [ -n "$SDLC_STAGE" ] && CURL_ARGS+=(-F "sdlcStage=${SDLC_STAGE}")
555
559
  [ -n "$TEST_CYCLE" ] && CURL_ARGS+=(-F "testCycleId=${TEST_CYCLE}")
556
- [ -n "$EVIDENCE_SCOPE" ] && CURL_ARGS+=(-F "evidenceScope=${EVIDENCE_SCOPE}")
560
+ [ -n "$TRANSPORT_EVIDENCE_SCOPE" ] && CURL_ARGS+=(-F "evidenceScope=${TRANSPORT_EVIDENCE_SCOPE}")
557
561
  [ -n "$TEST_CYCLE_RECORD_ID" ] && CURL_ARGS+=(-F "testCycleRecordId=${TEST_CYCLE_RECORD_ID}")
558
562
  [ -n "$SENTINEL_CONTENT" ] && CURL_ARGS+=(-F "sentinelContent=${SENTINEL_CONTENT}")
559
563
  [ -n "$COMMIT_TIMESTAMP" ] && CURL_ARGS+=(-F "commitTimestamp=${COMMIT_TIMESTAMP}")
@@ -1,6 +1,11 @@
1
1
  ---
2
2
  title: "Incident Report"
3
3
  incident_id: "REPLACE — e.g. INC-2026-001"
4
+ incident_kind: "incident"
5
+ source_release: "REPLACE — REQ-XXX or vYYYY.MM.DD.N"
6
+ release_scope: "REPLACE — optional broader owning release/scope"
7
+ source_issue: "REPLACE — GitHub issue number"
8
+ semantic_id: "REPLACE — stable id, usually incident_id or source_issue"
4
9
  severity: "REPLACE — low | medium | high | critical"
5
10
  detected_at: "REPLACE — ISO-8601 with timezone"
6
11
  resolved_at: "REPLACE — ISO-8601 or 'ongoing'"
@@ -30,7 +35,7 @@ last_reviewed_at: "REPLACE — YYYY-MM-DD"
30
35
  ## Uploading this artefact
31
36
 
32
37
  - **File path:** `compliance/governance/incident-report-<id>.md` (per-incident — recommended; the `incident-export.yml` workflow auto-produces these from closed GitHub issues labelled `incident`). The bare `incident-report.md` is the unedited starter — kept on disk as a reference but **skipped by the uploader** until you replace the STARTER TEMPLATE banner.
33
- - **Upload trigger:** automatic — on every push to `develop` that touches `compliance/**`, `compliance-evidence.yml` globs `incident-report*.md` under both layouts and uploads each non-stub file as `incident_report` evidence via the `upload_governance` helper. The starter stub is filtered out centrally by `upload-evidence.sh` (devaudit#133).
38
+ - **Upload trigger:** automatic — on every push to `develop` that touches `compliance/**`, `compliance-evidence.yml` considers `incident-report*.md` under both layouts, but uploads only files whose frontmatter `source_release` / `release_scope` matches the current derived release. Historical files remain on disk and are skipped unless an explicit bundle manifest grants inheritance; inherited files are still uploaded to their source release, not relabelled as the current release.
34
39
  - **Verify after merge:** open `/projects/<slug>/compliance`. `ISO29119.3.5.4` flips to COVERED only when a non-stub `incident-report*.md` lands. `SOC2.CC7.2`, `GDPR.Art-33`, `GDPR.Art-34` flip only when the relevant attribution sections below are non-stub.
35
40
  - **Refresh cadence:** none — incidents are point-in-time. Authoring is event-driven.
36
41
 
@@ -1,5 +1,8 @@
1
1
  ---
2
2
  incident_id: "NIL-<version>"
3
+ incident_kind: "no_incidents_attestation"
4
+ source_release: "<version>"
5
+ semantic_id: "NIL-<version>"
3
6
  severity: "N/A"
4
7
  detected_at: "<release date>"
5
8
  resolved_at: "N/A"
@@ -10,12 +13,12 @@ status: "nil"
10
13
 
11
14
  ## Attestation
12
15
 
13
- No incidents or defects were discovered during the test cycle for release `<version>`.
16
+ No incidents or defects were discovered during the test execution for release `<version>`.
14
17
 
15
18
  ## Scope
16
19
 
17
20
  - **Release:** <version>
18
- - **Test cycle:** <description of test cycle — e.g. "E2E Regression run on <date>", "Phase 6 full suite + Phase 3 re-run">
21
+ - **Test cycle:** <description of test execution — e.g. "E2E Regression run on <date>", "Phase 6 full suite + Phase 3 re-run">
19
22
  - **Test cases executed:** <count>
20
23
  - **Test cases passed:** <count>
21
24
  - **Test cases failed:** 0
@@ -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 ==="
@@ -43,6 +43,9 @@ PY
43
43
  }
44
44
 
45
45
  HEAD_REF="${HEAD_REF:-}"
46
+ BASE_REF="${BASE_REF:-}"
47
+ INTEGRATION_BRANCH="${INTEGRATION_BRANCH:-develop}"
48
+ RELEASE_BRANCH="${RELEASE_BRANCH:-main}"
46
49
  if is_hotfix_branch "$HEAD_REF"; then
47
50
  echo "Hotfix PR detected (${HEAD_REF}) — release scope integrity check skipped."
48
51
  exit 0
@@ -51,7 +54,11 @@ fi
51
54
  if [ ! -x scripts/derive-release-version.sh ]; then
52
55
  chmod +x scripts/derive-release-version.sh 2>/dev/null || true
53
56
  fi
54
- CURRENT_RELEASE=$(bash scripts/derive-release-version.sh)
57
+ ALLOW_PENDING_TICKET_FALLBACK=0
58
+ if [ "$HEAD_REF" = "$INTEGRATION_BRANCH" ] && [ "$BASE_REF" = "$RELEASE_BRANCH" ]; then
59
+ ALLOW_PENDING_TICKET_FALLBACK=1
60
+ fi
61
+ CURRENT_RELEASE=$(DEVAUDIT_ALLOW_PENDING_TICKET_FALLBACK="$ALLOW_PENDING_TICKET_FALLBACK" bash scripts/derive-release-version.sh)
55
62
  if [ -z "$CURRENT_RELEASE" ]; then
56
63
  echo "::error::Could not derive the effective release scope from the current branch head."
57
64
  exit 1
@@ -134,18 +134,67 @@ assert_exit "hotfix branch skips" 0 "$CODE"
134
134
  assert_grep "hotfix skip message" 'Hotfix PR detected' "$OUT"
135
135
  echo ""
136
136
 
137
- echo "--- Test 5: bare-date releases require an explicit standalone declaration ---"
138
- make_fixture "$WORK/test5" "chore: standalone housekeeping release"
139
- cat > "$WORK/test5/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-v2026.07.18.json" <<'EOF'
137
+ echo "--- Test 5: develop-to-main promotion may use exactly one pending ticket ---"
138
+ make_fixture "$WORK/test5" "chore: integration head without commit tag"
139
+ cat > "$WORK/test5/compliance/pending-releases/RELEASE-TICKET-REQ-094.md" <<'EOF'
140
+ # Release Ticket — REQ-094
141
+ EOF
142
+ OUT="$WORK/test5.out"
143
+ if run_check "$WORK/test5" "$OUT" env HEAD_REF="develop" BASE_REF="main" PR_TITLE="Release: REQ-094" PR_BODY=$'## Release\n- Release: REQ-094\n'; then
144
+ CODE=0
145
+ else
146
+ CODE=$?
147
+ fi
148
+ assert_exit "develop-to-main single pending ticket passes" 0 "$CODE"
149
+ assert_grep "pending ticket promotion resolves REQ" 'Release Scope Integrity verified for REQ-094' "$OUT"
150
+ echo ""
151
+
152
+ echo "--- Test 6: non-promotion context cannot use pending ticket fallback ---"
153
+ make_fixture "$WORK/test6" "chore: integration head without commit tag"
154
+ cat > "$WORK/test6/compliance/pending-releases/RELEASE-TICKET-REQ-094.md" <<'EOF'
155
+ # Release Ticket — REQ-094
156
+ EOF
157
+ OUT="$WORK/test6.out"
158
+ if run_check "$WORK/test6" "$OUT" env HEAD_REF="feature/demo" BASE_REF="develop" PR_TITLE="Release: REQ-094" PR_BODY=$'## Release\n- Release: REQ-094\n'; then
159
+ CODE=0
160
+ else
161
+ CODE=$?
162
+ fi
163
+ assert_exit "non-promotion single pending ticket fails" 1 "$CODE"
164
+ assert_grep "non-promotion derives bare date" 'Derived effective scope: v[0-9]{4}\.[0-9]{2}\.[0-9]{2}' "$OUT"
165
+ echo ""
166
+
167
+ echo "--- Test 7: develop-to-main with ambiguous pending tickets fails closed ---"
168
+ make_fixture "$WORK/test7" "chore: integration head without commit tag"
169
+ cat > "$WORK/test7/compliance/pending-releases/RELEASE-TICKET-REQ-094.md" <<'EOF'
170
+ # Release Ticket — REQ-094
171
+ EOF
172
+ cat > "$WORK/test7/compliance/pending-releases/RELEASE-TICKET-REQ-095.md" <<'EOF'
173
+ # Release Ticket — REQ-095
174
+ EOF
175
+ OUT="$WORK/test7.out"
176
+ if run_check "$WORK/test7" "$OUT" env HEAD_REF="develop" BASE_REF="main" PR_TITLE="Release: REQ-094" PR_BODY=$'## Release\n- Release: REQ-094\n'; then
177
+ CODE=0
178
+ else
179
+ CODE=$?
180
+ fi
181
+ assert_exit "ambiguous pending tickets fail" 1 "$CODE"
182
+ assert_grep "ambiguous promotion falls back to bare date" 'Derived effective scope: v[0-9]{4}\.[0-9]{2}\.[0-9]{2}' "$OUT"
183
+ echo ""
184
+
185
+ echo "--- Test 8: bare-date releases require an explicit standalone declaration ---"
186
+ make_fixture "$WORK/test8" "chore: standalone housekeeping release"
187
+ BARE_DATE_VERSION="v$(date -u +%Y.%m.%d)"
188
+ cat > "$WORK/test8/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
140
189
  {
141
190
  "schemaVersion": 1,
142
- "version": "v2026.07.18",
191
+ "version": "${BARE_DATE_VERSION}",
143
192
  "releaseMode": "standalone_housekeeping",
144
193
  "reason": "Urgent operational change cannot wait for the next tracked requirement release."
145
194
  }
146
195
  EOF
147
- 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
196
+ OUT="$WORK/test8.out"
197
+ if run_check "$WORK/test8" "$OUT" env PR_TITLE="Standalone housekeeping promotion: ${BARE_DATE_VERSION}" PR_BODY=$"## Release\n- Release: ${BARE_DATE_VERSION}\n"; then
149
198
  CODE=0
150
199
  else
151
200
  CODE=$?
@@ -154,18 +203,18 @@ assert_exit "declared standalone housekeeping release passes" 0 "$CODE"
154
203
  assert_grep "standalone validation is emitted" 'Standalone housekeeping declaration is valid' "$OUT"
155
204
  echo ""
156
205
 
157
- echo "--- Test 6: bare-date releases without the exception marker fail ---"
158
- make_fixture "$WORK/test6" "chore: standalone housekeeping release"
159
- cat > "$WORK/test6/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-v2026.07.18.json" <<'EOF'
206
+ echo "--- Test 9: bare-date releases without the exception marker fail ---"
207
+ make_fixture "$WORK/test9" "chore: standalone housekeeping release"
208
+ cat > "$WORK/test9/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
160
209
  {
161
210
  "schemaVersion": 1,
162
- "version": "v2026.07.18",
211
+ "version": "${BARE_DATE_VERSION}",
163
212
  "releaseMode": "standalone_housekeeping",
164
213
  "reason": "Urgent operational change cannot wait for the next tracked requirement release."
165
214
  }
166
215
  EOF
167
- 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
216
+ OUT="$WORK/test9.out"
217
+ if run_check "$WORK/test9" "$OUT" env PR_TITLE="Release: ${BARE_DATE_VERSION}" PR_BODY=$"## Release\n- Release: ${BARE_DATE_VERSION}\n"; then
169
218
  CODE=0
170
219
  else
171
220
  CODE=$?
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Validate host-level prerequisites for self-hosted CI runners.
3
+
4
+ set -euo pipefail
5
+
6
+ MIN_WATCHES="${MIN_INOTIFY_WATCHES:-524288}"
7
+ MIN_INSTANCES="${MIN_INOTIFY_INSTANCES:-512}"
8
+ RUNNER_ENVIRONMENT="${DEVAUDIT_RUNNER_ENVIRONMENT:-${RUNNER_ENVIRONMENT:-}}"
9
+ SYSCTL_BIN="${SYSCTL_BIN:-sysctl}"
10
+ SYSCTL_CONF_DIR="${SYSCTL_CONF_DIR:-/etc/sysctl.d}"
11
+ APPLY=false
12
+ FORCE=false
13
+
14
+ usage() {
15
+ cat <<EOF
16
+ Usage: check-self-hosted-runner.sh [--apply] [--force]
17
+
18
+ Validates Linux self-hosted runner prerequisites needed by Next.js/Turbopack
19
+ and Playwright E2E jobs.
20
+
21
+ Options:
22
+ --apply Persist and apply inotify sysctl minima. Requires permission to
23
+ write ${SYSCTL_CONF_DIR} and run sysctl -w.
24
+ --force Run checks even when DEVAUDIT_RUNNER_ENVIRONMENT is not self-hosted.
25
+ EOF
26
+ }
27
+
28
+ while [ "$#" -gt 0 ]; do
29
+ case "$1" in
30
+ --apply) APPLY=true ;;
31
+ --force) FORCE=true ;;
32
+ -h|--help) usage; exit 0 ;;
33
+ *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
34
+ esac
35
+ shift
36
+ done
37
+
38
+ if [ "${FORCE}" != "true" ] && [ -n "${RUNNER_ENVIRONMENT}" ] && [ "${RUNNER_ENVIRONMENT}" != "self-hosted" ]; then
39
+ echo "Runner environment is ${RUNNER_ENVIRONMENT}; self-hosted prerequisite checks skipped."
40
+ exit 0
41
+ fi
42
+
43
+ OS_NAME="$(uname -s 2>/dev/null || echo unknown)"
44
+ if [ "${OS_NAME}" != "Linux" ]; then
45
+ echo "Runner OS is ${OS_NAME}; Linux inotify prerequisite checks skipped."
46
+ exit 0
47
+ fi
48
+
49
+ read_sysctl() {
50
+ local key="$1"
51
+ "${SYSCTL_BIN}" -n "$key" 2>/dev/null || true
52
+ }
53
+
54
+ write_sysctl_config() {
55
+ mkdir -p "${SYSCTL_CONF_DIR}"
56
+ cat > "${SYSCTL_CONF_DIR}/99-metasession-ci-inotify.conf" <<EOF
57
+ fs.inotify.max_user_watches=${MIN_WATCHES}
58
+ fs.inotify.max_user_instances=${MIN_INSTANCES}
59
+ EOF
60
+ }
61
+
62
+ if [ "${APPLY}" = "true" ]; then
63
+ echo "Persisting self-hosted runner inotify prerequisites..."
64
+ write_sysctl_config
65
+ "${SYSCTL_BIN}" -w "fs.inotify.max_user_watches=${MIN_WATCHES}"
66
+ "${SYSCTL_BIN}" -w "fs.inotify.max_user_instances=${MIN_INSTANCES}"
67
+ fi
68
+
69
+ CURRENT_WATCHES="$(read_sysctl fs.inotify.max_user_watches)"
70
+ CURRENT_INSTANCES="$(read_sysctl fs.inotify.max_user_instances)"
71
+
72
+ if ! [[ "${CURRENT_WATCHES}" =~ ^[0-9]+$ ]] || ! [[ "${CURRENT_INSTANCES}" =~ ^[0-9]+$ ]]; then
73
+ echo "::error::Unable to read Linux inotify sysctl values on this self-hosted runner." >&2
74
+ echo "Run: sudo bash scripts/check-self-hosted-runner.sh --apply" >&2
75
+ exit 1
76
+ fi
77
+
78
+ FAILED=false
79
+ if [ "${CURRENT_WATCHES}" -lt "${MIN_WATCHES}" ]; then
80
+ echo "::error::fs.inotify.max_user_watches=${CURRENT_WATCHES}, expected at least ${MIN_WATCHES}." >&2
81
+ FAILED=true
82
+ fi
83
+ if [ "${CURRENT_INSTANCES}" -lt "${MIN_INSTANCES}" ]; then
84
+ echo "::error::fs.inotify.max_user_instances=${CURRENT_INSTANCES}, expected at least ${MIN_INSTANCES}." >&2
85
+ FAILED=true
86
+ fi
87
+
88
+ if [ "${FAILED}" = "true" ]; then
89
+ cat >&2 <<EOF
90
+ Self-hosted runner is missing durable inotify capacity for Turbopack/Playwright E2E.
91
+
92
+ Apply on the runner host:
93
+ sudo bash scripts/check-self-hosted-runner.sh --apply
94
+
95
+ Expected persistent file:
96
+ ${SYSCTL_CONF_DIR}/99-metasession-ci-inotify.conf
97
+
98
+ Do not classify this as product-test evidence; repair the runner and rerun CI.
99
+ EOF
100
+ exit 1
101
+ fi
102
+
103
+ echo "Self-hosted runner prerequisites passed: fs.inotify.max_user_watches=${CURRENT_WATCHES}, fs.inotify.max_user_instances=${CURRENT_INSTANCES}."
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ CHECKER="$SCRIPT_DIR/check-self-hosted-runner.sh"
6
+
7
+ PASS=0
8
+ FAIL=0
9
+
10
+ ok() {
11
+ echo " PASS: $1"
12
+ PASS=$((PASS + 1))
13
+ }
14
+
15
+ no() {
16
+ echo " FAIL: $1"
17
+ FAIL=$((FAIL + 1))
18
+ }
19
+
20
+ run_case() {
21
+ local name="$1"; shift
22
+ OUT="$(mktemp)"
23
+ set +e
24
+ "$@" >"$OUT" 2>&1
25
+ CODE=$?
26
+ set -e
27
+ echo "$name"
28
+ }
29
+
30
+ WORK="$(mktemp -d -t runner-check-test-XXXX)"
31
+ trap 'rm -rf "$WORK"' EXIT
32
+
33
+ SYSCTL_FAKE="$WORK/sysctl"
34
+ cat > "$SYSCTL_FAKE" <<'EOF'
35
+ #!/usr/bin/env bash
36
+ set -euo pipefail
37
+ case "${1:-}" in
38
+ -n)
39
+ case "$2" in
40
+ fs.inotify.max_user_watches) echo "${FAKE_WATCHES:-524288}" ;;
41
+ fs.inotify.max_user_instances) echo "${FAKE_INSTANCES:-512}" ;;
42
+ *) exit 1 ;;
43
+ esac
44
+ ;;
45
+ -w)
46
+ echo "$2"
47
+ ;;
48
+ *)
49
+ exit 1
50
+ ;;
51
+ esac
52
+ EOF
53
+ chmod +x "$SYSCTL_FAKE"
54
+
55
+ run_case "Case 1: GitHub-hosted runner no-ops" env DEVAUDIT_RUNNER_ENVIRONMENT=github-hosted SYSCTL_BIN="$SYSCTL_FAKE" "$CHECKER"
56
+ [ "$CODE" -eq 0 ] && grep -q "skipped" "$OUT" && ok "hosted runner skipped" || no "hosted runner should skip"
57
+
58
+ run_case "Case 2: low limits fail with actionable remediation" env DEVAUDIT_RUNNER_ENVIRONMENT=self-hosted FAKE_WATCHES=65536 FAKE_INSTANCES=128 SYSCTL_BIN="$SYSCTL_FAKE" "$CHECKER"
59
+ [ "$CODE" -eq 1 ] && grep -q "sudo bash scripts/check-self-hosted-runner.sh --apply" "$OUT" && ok "low limits fail with remediation" || no "low limits should fail"
60
+
61
+ run_case "Case 3: sufficient limits pass" env DEVAUDIT_RUNNER_ENVIRONMENT=self-hosted FAKE_WATCHES=524288 FAKE_INSTANCES=512 SYSCTL_BIN="$SYSCTL_FAKE" "$CHECKER"
62
+ [ "$CODE" -eq 0 ] && grep -q "prerequisites passed" "$OUT" && ok "sufficient limits pass" || no "sufficient limits should pass"
63
+
64
+ CONF_DIR="$WORK/sysctl.d"
65
+ run_case "Case 4: --apply writes persistent config" env DEVAUDIT_RUNNER_ENVIRONMENT=self-hosted SYSCTL_BIN="$SYSCTL_FAKE" SYSCTL_CONF_DIR="$CONF_DIR" "$CHECKER" --apply
66
+ if [ "$CODE" -eq 0 ] && grep -q "fs.inotify.max_user_watches=524288" "$CONF_DIR/99-metasession-ci-inotify.conf" && grep -q "fs.inotify.max_user_instances=512" "$CONF_DIR/99-metasession-ci-inotify.conf"; then
67
+ ok "--apply persists config"
68
+ else
69
+ no "--apply should persist config"
70
+ fi
71
+
72
+ echo
73
+ echo "Result: $PASS passed, $FAIL failed"
74
+ [ "$FAIL" = "0" ]