@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.
- package/dist/index.js +36 -34
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/upload-evidence.sh +23 -19
- package/sdlc/files/_common/governance/incident-report.md.template +6 -1
- package/sdlc/files/_common/governance/nil-incident-report.md.template +5 -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.sh +8 -1
- package/sdlc/files/_common/scripts/check-release-pr-scope.test.sh +61 -12
- package/sdlc/files/_common/scripts/check-self-hosted-runner.sh +103 -0
- package/sdlc/files/_common/scripts/check-self-hosted-runner.test.sh +74 -0
- 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/scripts/record-uat-execution.sh +195 -0
- package/sdlc/files/_common/scripts/record-uat-execution.test.sh +144 -0
- package/sdlc/files/_common/scripts/render-test-executions.sh +146 -0
- package/sdlc/files/_common/scripts/{render-test-cycles.test.sh → render-test-executions.test.sh} +12 -18
- package/sdlc/files/_common/scripts/{report-test-cycle.sh → report-test-execution.sh} +66 -74
- package/sdlc/files/_common/scripts/report-test-execution.test.sh +177 -0
- package/sdlc/files/_common/scripts/submit-for-uat-review.sh +21 -0
- package/sdlc/files/_common/scripts/upload-evidence.sh +23 -19
- package/sdlc/files/_common/scripts/validate-commits.sh +6 -4
- package/sdlc/files/_common/scripts/validate-commits.test.sh +15 -0
- package/sdlc/files/_common/skills/e2e-test-engineer/SKILL.md +9 -7
- package/sdlc/files/_common/skills/e2e-test-engineer/references/e2e-regression-3-tier.yml +62 -10
- package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +19 -16
- package/sdlc/files/ci/ci.yml.template +86 -46
- package/sdlc/files/ci/compliance-evidence.yml.template +173 -49
- package/sdlc/files/ci/feature-e2e.yml.template +17 -17
- package/sdlc/files/ci/incident-export.yml.template +18 -4
- package/sdlc/files/ci/post-deploy-prod.yml.template +92 -48
- package/sdlc/files/ci/python/ci.yml.template +14 -14
- package/sdlc/files/ci/quality-gates-provenance.yml.template +3 -0
- package/sdlc/files/ci/reconcile-deployment.yml.template +59 -0
- package/sdlc/package.json +1 -1
- package/sdlc/src/blueprints/3-compile-evidence.raw.md +10 -10
- package/sdlc/src/blueprints/4-submit-for-review.raw.md +1 -1
- package/sdlc/src/blueprints/5-deploy-main.raw.md +1 -1
- package/sdlc/src/blueprints/implementing-an-sdlc-issue.raw.md +3 -3
- 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.
|
|
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.
|
|
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-
|
|
46
|
-
# ID). Forwarded
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# --test-
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
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-
|
|
139
|
+
--test-execution) TEST_CYCLE="$2"; shift 2 ;;
|
|
140
140
|
--evidence-scope) EVIDENCE_SCOPE="$2"; shift 2 ;;
|
|
141
|
-
--test-
|
|
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|
|
|
170
|
-
echo "Error: --evidence-scope must be one of: release, stage,
|
|
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" != "
|
|
174
|
-
echo "Error: --test-
|
|
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 "$
|
|
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`
|
|
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
|
|
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
|
|
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 "
|
|
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 ==="
|
|
@@ -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
|
-
|
|
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:
|
|
138
|
-
make_fixture "$WORK/test5" "chore:
|
|
139
|
-
cat > "$WORK/test5/compliance/
|
|
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": "
|
|
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/
|
|
148
|
-
if run_check "$WORK/
|
|
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
|
|
158
|
-
make_fixture "$WORK/
|
|
159
|
-
cat > "$WORK/
|
|
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": "
|
|
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/
|
|
168
|
-
if run_check "$WORK/
|
|
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" ]
|