@biffo/cli 0.301.13 → 0.301.15
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/_skeletons/plugin-template/.github/workflows/ci.yml +12 -0
- package/_skeletons/plugin-template/scripts/core-version-preflight.sh +173 -0
- package/_skeletons/plugin-template/scripts/core-version-preflight.test.sh +220 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +12 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +65 -13
- package/_skeletons/sibling-template/scripts/core-version-preflight.sh +173 -0
- package/_skeletons/sibling-template/scripts/core-version-preflight.test.sh +220 -0
- package/package.json +1 -1
|
@@ -126,6 +126,18 @@ jobs:
|
|
|
126
126
|
# py-dependency-audit.sh already carry as inert-but-verified copies
|
|
127
127
|
# in a Python-only or JS-only sibling.
|
|
128
128
|
run: sh scripts/core-revision-preflight.test.sh
|
|
129
|
+
- name: Core-version preflight — self-test
|
|
130
|
+
if: ${{ !cancelled() }}
|
|
131
|
+
# scripts/core-version-preflight.sh (biffo-template#1605) is
|
|
132
|
+
# distributed here too, verbatim, via shared-files.json's `files` —
|
|
133
|
+
# same reasoning as the Core-revision preflight self-test above: a
|
|
134
|
+
# plugin repo has no deploy.yml, so it is never invoked for a REAL
|
|
135
|
+
# preflight here the way the sibling skeleton's deploy.yml invokes
|
|
136
|
+
# it. This is the caller that keeps it off the #1413 zero-caller
|
|
137
|
+
# list anyway: it proves the script itself still blocks a stale
|
|
138
|
+
# core, passes a current one, and fails CLOSED when the health
|
|
139
|
+
# document is missing or reports "unknown", against a stubbed curl.
|
|
140
|
+
run: sh scripts/core-version-preflight.test.sh
|
|
129
141
|
- name: Guard self-test wiring — this repo's own scripts/*.test.sh
|
|
130
142
|
if: ${{ !cancelled() }}
|
|
131
143
|
# Level-3 self-audit (biffo-template#1710): the template repo's own
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Core-version preflight (#1605).
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A sibling can deploy against a core instance whose TEMPLATE version is
|
|
8
|
+
# older than the sibling's code needs — a different failure from #1604's
|
|
9
|
+
# route-ordering race. #1604 asks "has core shipped the specific route my
|
|
10
|
+
# code depends on RIGHT NOW"; this asks "is the instance running at least
|
|
11
|
+
# the core template capability my code was written against". The two are
|
|
12
|
+
# deliberately separate scripts and separate deploy jobs, because they read
|
|
13
|
+
# different signals and fail for different reasons — conflating them would
|
|
14
|
+
# make either failure's message describe the wrong problem. This script does
|
|
15
|
+
# NOT address, and is not a substitute for, #1604's deploy-ordering race.
|
|
16
|
+
#
|
|
17
|
+
# ## The signal
|
|
18
|
+
#
|
|
19
|
+
# `services/api/src/api/routers/health.py` returns `core_version()`, baked
|
|
20
|
+
# in at package time by `scripts/resolve-core-version.sh`, and is publicly
|
|
21
|
+
# reachable with no credential at `/api/v1/health` on every deployed
|
|
22
|
+
# instance (`{"status":"ok","version":"0.287.12"}`). Nothing new is
|
|
23
|
+
# published for this script — it reads a signal that already exists.
|
|
24
|
+
#
|
|
25
|
+
# ## Why NOT biffo.sibling.json's `template_version`
|
|
26
|
+
#
|
|
27
|
+
# The obvious-looking home for a declared floor is the sibling's own
|
|
28
|
+
# `template_version` field. It is the wrong field: ADR-0007 states it is
|
|
29
|
+
# stamped ONCE at scaffold time from `getLatestCoreVersion()`, is
|
|
30
|
+
# "visibility only", and explicitly "nothing reads or compares the field
|
|
31
|
+
# yet" — it is a backward PROVENANCE stamp that only ever falls further
|
|
32
|
+
# behind, not a forward REQUIREMENT that rises as a sibling adopts newer
|
|
33
|
+
# core capability. Measured live across all five real siblings at the time
|
|
34
|
+
# #1605 was picked up: four had no `template_version` at all and the fifth
|
|
35
|
+
# was ~80 minor versions behind the running core — reusing it as a floor
|
|
36
|
+
# would be inert for four siblings and trivially satisfied by the fifth.
|
|
37
|
+
# Mirroring #1604's already-proven shape instead: a per-environment GitHub
|
|
38
|
+
# Actions repo variable the sibling sets by hand, the same way
|
|
39
|
+
# CORE_MIN_ROUTE_REVISION is set — no JSON schema change, no ADR amendment,
|
|
40
|
+
# no new document.
|
|
41
|
+
#
|
|
42
|
+
# ## Fail-closed (the #1363 class)
|
|
43
|
+
#
|
|
44
|
+
# A sibling that declares no minimum has nothing to check — that is a
|
|
45
|
+
# legitimate "I depend on nothing beyond whatever core happens to be
|
|
46
|
+
# running" state, and it passes with a notice. Once a minimum IS declared,
|
|
47
|
+
# absence of a valid version in the health response — an unreachable
|
|
48
|
+
# instance, a non-packaged deployment reporting the literal string
|
|
49
|
+
# "unknown" (health.py's own documented fallback), a malformed body — must
|
|
50
|
+
# never read as "satisfied". It fails, and says WHICH: "missing/unreachable"
|
|
51
|
+
# and "present but behind" are different facts, and a caller reading only
|
|
52
|
+
# the exit code must not be able to mistake one for the other from the log.
|
|
53
|
+
#
|
|
54
|
+
# ## Version comparison
|
|
55
|
+
#
|
|
56
|
+
# Both versions are dotted non-negative-integer strings (e.g. "0.287.12"),
|
|
57
|
+
# the shape every `core-v*` tag and every `biffo.core.json` `.version` takes
|
|
58
|
+
# (see resolve-core-version.sh). Compared segment-by-segment, numerically,
|
|
59
|
+
# left to right, with a missing trailing segment on either side treated as
|
|
60
|
+
# 0 — so "0.287" and "0.287.0" compare equal, and "0.9" is correctly less
|
|
61
|
+
# than "0.10". This is deliberately NOT full semver (no pre-release/build
|
|
62
|
+
# metadata) because nothing in this estate's version strings ever carries
|
|
63
|
+
# either.
|
|
64
|
+
#
|
|
65
|
+
# Exit codes:
|
|
66
|
+
# 0 = no minimum declared (nothing to check), or the instance is at/past it.
|
|
67
|
+
# 1 = the instance is behind the declared minimum, its health endpoint is
|
|
68
|
+
# unreachable, or its reported version could not be parsed. Every one
|
|
69
|
+
# of these is "not verified as safe" and none may be treated as a pass.
|
|
70
|
+
#
|
|
71
|
+
# ## Usage
|
|
72
|
+
#
|
|
73
|
+
# CORE_MIN_TEMPLATE_VERSION=0.250.0 \
|
|
74
|
+
# CORE_HEALTH_URL=https://dev.example.com/api/v1/health \
|
|
75
|
+
# sh scripts/core-version-preflight.sh
|
|
76
|
+
#
|
|
77
|
+
# CORE_MIN_TEMPLATE_VERSION — the minimum core template version this
|
|
78
|
+
# sibling's code needs. Set as a per-environment
|
|
79
|
+
# repo variable (vars.CORE_MIN_TEMPLATE_VERSION),
|
|
80
|
+
# the same way CORE_MIN_ROUTE_REVISION is set by
|
|
81
|
+
# hand. Unset/empty means "no floor declared"
|
|
82
|
+
# and the check is skipped.
|
|
83
|
+
# CORE_HEALTH_URL — where to fetch the instance's health document,
|
|
84
|
+
# typically "${CORE_API_URL}/api/v1/health".
|
|
85
|
+
# Required whenever a minimum is declared.
|
|
86
|
+
# PREFLIGHT_CURL — override the curl binary/wrapper (tests use
|
|
87
|
+
# this to point at a stub).
|
|
88
|
+
#
|
|
89
|
+
# Run this file's own tests: sh scripts/core-version-preflight.test.sh
|
|
90
|
+
|
|
91
|
+
set -u
|
|
92
|
+
|
|
93
|
+
CURL=${PREFLIGHT_CURL:-curl}
|
|
94
|
+
|
|
95
|
+
MIN=${CORE_MIN_TEMPLATE_VERSION:-}
|
|
96
|
+
if [ -z "$MIN" ]; then
|
|
97
|
+
# Loud on purpose, matching core-revision-preflight.sh's own reasoning: a
|
|
98
|
+
# green "Preflight — core template version" step must never look
|
|
99
|
+
# identical whether it actually checked something or checked nothing.
|
|
100
|
+
MSG="core-version-preflight: preflight did NOT run — CORE_MIN_TEMPLATE_VERSION is not set, so this sibling has declared no core-template-version dependency. This is 'nothing was checked', not 'core was checked and is ready'; set CORE_MIN_TEMPLATE_VERSION once this sibling depends on a specific core template version."
|
|
101
|
+
echo "::notice::${MSG}"
|
|
102
|
+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
103
|
+
echo "${MSG}" >> "$GITHUB_STEP_SUMMARY"
|
|
104
|
+
fi
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
is_version() {
|
|
109
|
+
printf '%s' "$1" | grep -Eq '^[0-9]+(\.[0-9]+)*$'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if ! is_version "$MIN"; then
|
|
113
|
+
echo "::error::core-version-preflight: CORE_MIN_TEMPLATE_VERSION='$MIN' is not a dotted non-negative-integer version (e.g. '0.250.0')." >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
URL=${CORE_HEALTH_URL:-}
|
|
118
|
+
if [ -z "$URL" ]; then
|
|
119
|
+
echo "::error::core-version-preflight: CORE_MIN_TEMPLATE_VERSION=$MIN is declared but CORE_HEALTH_URL is not set — cannot fetch the instance's health document to check it against. Refusing to proceed rather than assuming core is ready." >&2
|
|
120
|
+
exit 1
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
BODY_FILE=/tmp/core-version-preflight-body.$$
|
|
124
|
+
ERR_FILE=/tmp/core-version-preflight-err.$$
|
|
125
|
+
trap 'rm -f "$BODY_FILE" "$ERR_FILE"' EXIT
|
|
126
|
+
|
|
127
|
+
HTTP_CODE=$("$CURL" -sS --max-time 15 --retry 2 --retry-delay 2 \
|
|
128
|
+
-o "$BODY_FILE" -w '%{http_code}' "$URL" 2>"$ERR_FILE")
|
|
129
|
+
RC=$?
|
|
130
|
+
ERR=$(cat "$ERR_FILE" 2>/dev/null)
|
|
131
|
+
|
|
132
|
+
if [ "$RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
|
133
|
+
echo "::error::core-version-preflight: no health response available at $URL (curl exit $RC, http ${HTTP_CODE:-none}${ERR:+, $ERR}). An absent or unreachable health endpoint is treated as NOT satisfied — this is expected on a down instance, a network issue, or a not-yet-deployed core — and is a DIFFERENT fact from 'core is behind version $MIN'. Refusing to deploy rather than assuming core is ready." >&2
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
ACTUAL=$(command -v jq >/dev/null 2>&1 && jq -r 'if (.version | type) == "string" then .version else empty end' "$BODY_FILE" 2>/dev/null)
|
|
138
|
+
|
|
139
|
+
if [ -z "${ACTUAL:-}" ] || ! is_version "$ACTUAL"; then
|
|
140
|
+
# Deliberately covers health.py's own documented fallback: outside a
|
|
141
|
+
# packaged deployment it returns the literal string "unknown", which is
|
|
142
|
+
# not a version and must fail the same way a missing/malformed body does
|
|
143
|
+
# — never be silently treated as satisfied.
|
|
144
|
+
echo "::error::core-version-preflight: health document at $URL did not contain a valid dotted version in 'version' (got '${ACTUAL:-<empty>}'). Treating a malformed or unknown version the same as a missing one — refusing to deploy." >&2
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
version_ge() {
|
|
149
|
+
# Prints "1" if $1 >= $2, "0" otherwise — dotted numeric segments compared
|
|
150
|
+
# left to right, missing trailing segments on either side treated as 0.
|
|
151
|
+
awk -v a="$1" -v b="$2" '
|
|
152
|
+
BEGIN {
|
|
153
|
+
na = split(a, pa, ".")
|
|
154
|
+
nb = split(b, pb, ".")
|
|
155
|
+
n = (na > nb) ? na : nb
|
|
156
|
+
for (i = 1; i <= n; i++) {
|
|
157
|
+
x = (i <= na) ? pa[i] + 0 : 0
|
|
158
|
+
y = (i <= nb) ? pb[i] + 0 : 0
|
|
159
|
+
if (x > y) { print "1"; exit }
|
|
160
|
+
if (x < y) { print "0"; exit }
|
|
161
|
+
}
|
|
162
|
+
print "1"
|
|
163
|
+
}
|
|
164
|
+
'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if [ "$(version_ge "$ACTUAL" "$MIN")" = "1" ]; then
|
|
168
|
+
echo "core-version-preflight: OK — core is at version $ACTUAL, this sibling needs at least $MIN."
|
|
169
|
+
exit 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
echo "::error::core-version-preflight: core is at version $ACTUAL, but this sibling needs at least $MIN. The instance has not upgraded to the core template version this sibling depends on yet — refusing to deploy ahead of it." >&2
|
|
173
|
+
exit 1
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves core-version-preflight.sh (#1605) actually blocks a sibling from
|
|
4
|
+
# deploying against an instance whose core template version is too old,
|
|
5
|
+
# passes it once the instance has caught up, and fails CLOSED — never open
|
|
6
|
+
# — when the health document is missing, unreachable, or reports the
|
|
7
|
+
# "unknown" version health.py falls back to outside a packaged deployment.
|
|
8
|
+
#
|
|
9
|
+
# Stubs `curl` on PATH so this needs no network and no live deployment.
|
|
10
|
+
#
|
|
11
|
+
# Run: sh scripts/core-version-preflight.test.sh
|
|
12
|
+
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
16
|
+
TARGET="$SCRIPT_DIR/core-version-preflight.sh"
|
|
17
|
+
STUB_DIR=$(mktemp -d)
|
|
18
|
+
OUT_FILE=/tmp/core-version-preflight-test-out.$$
|
|
19
|
+
trap 'rm -rf "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
20
|
+
|
|
21
|
+
FAILURES=0
|
|
22
|
+
URL="https://dev.example.com/api/v1/health"
|
|
23
|
+
|
|
24
|
+
# _run <env assignments...> -- runs the target with PREFLIGHT_CURL pointed at
|
|
25
|
+
# the stub, capturing combined output to $OUT_FILE and the exit code into
|
|
26
|
+
# $LAST_RC. NOT run inside a subshell/command-substitution, matching
|
|
27
|
+
# core-revision-preflight.test.sh's own reasoning: an assertion made inside
|
|
28
|
+
# `out=$(...)` runs FAILURES++ in a subshell that never reaches the parent,
|
|
29
|
+
# so a real regression could not fail the test.
|
|
30
|
+
_run() {
|
|
31
|
+
env "$@" sh -c "PREFLIGHT_CURL='$STUB_DIR/curl' sh '$TARGET'" >"$OUT_FILE" 2>&1
|
|
32
|
+
LAST_RC=$?
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_assert_exit() {
|
|
36
|
+
# _assert_exit <scenario-name> <expected-exit>
|
|
37
|
+
name=$1
|
|
38
|
+
expected=$2
|
|
39
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
40
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
41
|
+
else
|
|
42
|
+
echo "FAIL: $name — expected exit $expected, got $LAST_RC"
|
|
43
|
+
echo "--- output ---"
|
|
44
|
+
cat "$OUT_FILE"
|
|
45
|
+
echo "--------------"
|
|
46
|
+
FAILURES=$((FAILURES + 1))
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_assert_output_contains() {
|
|
51
|
+
# _assert_output_contains <scenario-name> <needle>
|
|
52
|
+
name=$1
|
|
53
|
+
needle=$2
|
|
54
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
55
|
+
echo "PASS: $name mentions '$needle'"
|
|
56
|
+
else
|
|
57
|
+
echo "FAIL: $name — expected output to mention '$needle'"
|
|
58
|
+
echo "--- output ---"
|
|
59
|
+
cat "$OUT_FILE"
|
|
60
|
+
echo "--------------"
|
|
61
|
+
FAILURES=$((FAILURES + 1))
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# --- Stub curl factory --------------------------------------------------
|
|
66
|
+
# Writes a stub that returns a fixed http status + body for the one URL this
|
|
67
|
+
# script ever fetches, or simulates a transport failure.
|
|
68
|
+
_write_stub_ok() {
|
|
69
|
+
version=$1
|
|
70
|
+
cat > "$STUB_DIR/curl" <<STUB
|
|
71
|
+
#!/usr/bin/env sh
|
|
72
|
+
outfile=""
|
|
73
|
+
prev=""
|
|
74
|
+
for a in "\$@"; do
|
|
75
|
+
case "\$prev" in
|
|
76
|
+
-o) outfile="\$a" ;;
|
|
77
|
+
esac
|
|
78
|
+
prev="\$a"
|
|
79
|
+
done
|
|
80
|
+
printf '{"status": "ok", "version": "$version"}' > "\$outfile"
|
|
81
|
+
printf '200'
|
|
82
|
+
exit 0
|
|
83
|
+
STUB
|
|
84
|
+
chmod +x "$STUB_DIR/curl"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_write_stub_unreachable() {
|
|
88
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
89
|
+
#!/usr/bin/env sh
|
|
90
|
+
echo "curl: (6) Could not resolve host: dev.example.com" >&2
|
|
91
|
+
exit 6
|
|
92
|
+
STUB
|
|
93
|
+
chmod +x "$STUB_DIR/curl"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_write_stub_404() {
|
|
97
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
98
|
+
#!/usr/bin/env sh
|
|
99
|
+
outfile=""
|
|
100
|
+
prev=""
|
|
101
|
+
for a in "$@"; do
|
|
102
|
+
case "$prev" in
|
|
103
|
+
-o) outfile="$a" ;;
|
|
104
|
+
esac
|
|
105
|
+
prev="$a"
|
|
106
|
+
done
|
|
107
|
+
printf 'Not Found' > "$outfile"
|
|
108
|
+
printf '404'
|
|
109
|
+
exit 0
|
|
110
|
+
STUB
|
|
111
|
+
chmod +x "$STUB_DIR/curl"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_write_stub_never_called() {
|
|
115
|
+
# A curl invocation in this scenario is itself the bug — the script must
|
|
116
|
+
# short-circuit before ever fetching.
|
|
117
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
118
|
+
#!/usr/bin/env sh
|
|
119
|
+
echo "curl invoked when it should not have been" >&2
|
|
120
|
+
exit 99
|
|
121
|
+
STUB
|
|
122
|
+
chmod +x "$STUB_DIR/curl"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# 1. No CORE_MIN_TEMPLATE_VERSION declared at all — this sibling depends on
|
|
126
|
+
# no specific core template version. Must pass WITHOUT even invoking
|
|
127
|
+
# curl, and must say so LOUDLY: a GitHub Actions ::notice:: annotation
|
|
128
|
+
# (not a bare echo a reader would have to open the step log to find),
|
|
129
|
+
# and never wording that could be mistaken for "checked and fine".
|
|
130
|
+
_write_stub_never_called
|
|
131
|
+
_run -u CORE_MIN_TEMPLATE_VERSION -u CORE_HEALTH_URL --
|
|
132
|
+
_assert_exit "no minimum declared" 0
|
|
133
|
+
_assert_output_contains "no minimum declared — is a ::notice:: annotation" "::notice::"
|
|
134
|
+
_assert_output_contains "no minimum declared — says preflight did not run" "did NOT run"
|
|
135
|
+
|
|
136
|
+
# 1b. Same scenario, but with GITHUB_STEP_SUMMARY set as it is in a real
|
|
137
|
+
# GitHub Actions run — the skip must ALSO land in the job summary.
|
|
138
|
+
SUMMARY_FILE=/tmp/core-version-preflight-test-summary.$$
|
|
139
|
+
: > "$SUMMARY_FILE"
|
|
140
|
+
_write_stub_never_called
|
|
141
|
+
_run -u CORE_MIN_TEMPLATE_VERSION -u CORE_HEALTH_URL -- GITHUB_STEP_SUMMARY="$SUMMARY_FILE"
|
|
142
|
+
_assert_exit "no minimum declared, with GITHUB_STEP_SUMMARY set" 0
|
|
143
|
+
if grep -qF "did NOT run" "$SUMMARY_FILE" 2>/dev/null; then
|
|
144
|
+
echo "PASS: no minimum declared — writes to \$GITHUB_STEP_SUMMARY"
|
|
145
|
+
else
|
|
146
|
+
echo "FAIL: no minimum declared — expected \$GITHUB_STEP_SUMMARY ($SUMMARY_FILE) to mention 'did NOT run'"
|
|
147
|
+
echo "--- summary file ---"
|
|
148
|
+
cat "$SUMMARY_FILE" 2>/dev/null
|
|
149
|
+
echo "--------------------"
|
|
150
|
+
FAILURES=$((FAILURES + 1))
|
|
151
|
+
fi
|
|
152
|
+
rm -f "$SUMMARY_FILE"
|
|
153
|
+
|
|
154
|
+
# 2. Sibling requires a version core HAS reached (core is AHEAD) — passes.
|
|
155
|
+
_write_stub_ok "0.287.12"
|
|
156
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
157
|
+
_assert_exit "core ahead of required version (0.250.0 <= 0.287.12)" 0
|
|
158
|
+
_assert_output_contains "core ahead — names actual" "version 0.287.12"
|
|
159
|
+
|
|
160
|
+
# 2b. Exact match — core is AT the required version — passes.
|
|
161
|
+
_write_stub_ok "0.250.0"
|
|
162
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
163
|
+
_assert_exit "core exactly at required version" 0
|
|
164
|
+
|
|
165
|
+
# 2c. Lexicographic trap: "0.9" must compare LESS than "0.10" numerically,
|
|
166
|
+
# not greater as a naive string compare would report.
|
|
167
|
+
_write_stub_ok "0.9.0"
|
|
168
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.10.0 CORE_HEALTH_URL="$URL"
|
|
169
|
+
_assert_exit "0.9.0 is numerically behind 0.10.0 (string compare would say ahead)" 1
|
|
170
|
+
|
|
171
|
+
# 2d. Missing trailing segment treated as 0 — "0.287" == "0.287.0".
|
|
172
|
+
_write_stub_ok "0.287"
|
|
173
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.287.0 CORE_HEALTH_URL="$URL"
|
|
174
|
+
_assert_exit "0.287 satisfies a 0.287.0 floor (missing segment = 0)" 0
|
|
175
|
+
|
|
176
|
+
# 3. Sibling requires a version core has NOT reached — fails, naming both
|
|
177
|
+
# the required and actual versions.
|
|
178
|
+
_write_stub_ok "0.208.1"
|
|
179
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
180
|
+
_assert_exit "core behind required version" 1
|
|
181
|
+
_assert_output_contains "core behind — names actual" "version 0.208.1"
|
|
182
|
+
_assert_output_contains "core behind — names required" "at least 0.250.0"
|
|
183
|
+
|
|
184
|
+
# 4. No health document available at all (transport failure) — fails
|
|
185
|
+
# CLOSED, and says the health endpoint was missing rather than that core
|
|
186
|
+
# is behind.
|
|
187
|
+
_write_stub_unreachable
|
|
188
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
189
|
+
_assert_exit "health endpoint unreachable" 1
|
|
190
|
+
_assert_output_contains "health endpoint unreachable — says missing, not behind" "no health response available"
|
|
191
|
+
|
|
192
|
+
# 5. Health endpoint 404s — fails CLOSED, same "missing" message as
|
|
193
|
+
# scenario 4, never "core is behind".
|
|
194
|
+
_write_stub_404
|
|
195
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
196
|
+
_assert_exit "health endpoint 404" 1
|
|
197
|
+
_assert_output_contains "health endpoint 404 — says missing" "no health response available"
|
|
198
|
+
|
|
199
|
+
# 6. Instance reports the literal "unknown" version — health.py's own
|
|
200
|
+
# documented fallback outside a packaged deployment. Must fail CLOSED
|
|
201
|
+
# exactly like a malformed/missing body, never be treated as satisfied.
|
|
202
|
+
_write_stub_ok "unknown"
|
|
203
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
204
|
+
_assert_exit "instance reports 'unknown' version" 1
|
|
205
|
+
_assert_output_contains "'unknown' version — treated as malformed, not satisfied" "did not contain a valid dotted version"
|
|
206
|
+
|
|
207
|
+
# 7. A minimum is declared but CORE_HEALTH_URL is not set — must fail
|
|
208
|
+
# without invoking curl (misconfiguration, not "core is ready").
|
|
209
|
+
_write_stub_never_called
|
|
210
|
+
_run -u CORE_HEALTH_URL -- CORE_MIN_TEMPLATE_VERSION=0.250.0
|
|
211
|
+
_assert_exit "minimum declared but URL unset" 1
|
|
212
|
+
|
|
213
|
+
echo
|
|
214
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
215
|
+
echo "core-version-preflight.test.sh: all checks passed."
|
|
216
|
+
exit 0
|
|
217
|
+
else
|
|
218
|
+
echo "core-version-preflight.test.sh: $FAILURES check(s) failed."
|
|
219
|
+
exit 1
|
|
220
|
+
fi
|
|
@@ -274,6 +274,18 @@ jobs:
|
|
|
274
274
|
# is missing, against a stubbed curl. Without this caller the script
|
|
275
275
|
# is the #1413 zero-caller class the instant it lands.
|
|
276
276
|
run: sh ../../scripts/core-revision-preflight.test.sh
|
|
277
|
+
- name: Core-version preflight — self-test
|
|
278
|
+
if: ${{ !cancelled() }}
|
|
279
|
+
# Same shape as the two self-tests above: not exercising a real core
|
|
280
|
+
# deployment (CI has none to reach), proves
|
|
281
|
+
# scripts/core-version-preflight.sh — the deploy.yml
|
|
282
|
+
# preflight-core-version job (#1605) that blocks this sibling from
|
|
283
|
+
# deploying against an instance whose core TEMPLATE version is too
|
|
284
|
+
# old — still blocks a stale core, passes a current one, and fails
|
|
285
|
+
# CLOSED when the health document is missing or reports "unknown",
|
|
286
|
+
# against a stubbed curl. Without this caller the script is the
|
|
287
|
+
# #1413 zero-caller class the instant it lands.
|
|
288
|
+
run: sh ../../scripts/core-version-preflight.test.sh
|
|
277
289
|
- name: Guard self-test wiring — this repo's own scripts/*.test.sh
|
|
278
290
|
if: ${{ !cancelled() }}
|
|
279
291
|
working-directory: .
|
|
@@ -190,9 +190,52 @@ jobs:
|
|
|
190
190
|
CORE_ROUTE_REVISION_URL: ${{ vars.CORE_PORTAL_URL }}/.well-known/route-revision.json
|
|
191
191
|
run: sh scripts/core-revision-preflight.sh
|
|
192
192
|
|
|
193
|
+
# A DIFFERENT dependency from preflight-core-revision above: that job asks
|
|
194
|
+
# "has core shipped the specific ROUTE my code depends on right now" (a
|
|
195
|
+
# deploy-ORDERING race, #1604/#903). This asks "is the instance running at
|
|
196
|
+
# least the core TEMPLATE version my code was written against" — a much
|
|
197
|
+
# coarser, cheaper signal that does not require core to have published
|
|
198
|
+
# anything new. It reuses core's existing, already-public /api/v1/health
|
|
199
|
+
# endpoint (services/api/src/api/routers/health.py), which reports
|
|
200
|
+
# core_version() baked in at package time. #1605 split this off #903
|
|
201
|
+
# deliberately: it does NOT address #903's ordering race, only the
|
|
202
|
+
# separate case of a sibling deploying against a core template that is
|
|
203
|
+
# simply too old for what the sibling needs.
|
|
204
|
+
#
|
|
205
|
+
# Runs in parallel with deploy-infra and preflight-core-revision (all three
|
|
206
|
+
# depend only on resolve-environment) so it costs no extra wall-clock on a
|
|
207
|
+
# deploy that passes; deploy-app then needs all three.
|
|
208
|
+
preflight-core-version:
|
|
209
|
+
name: Preflight — core template version
|
|
210
|
+
needs: resolve-environment
|
|
211
|
+
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
212
|
+
environment: ${{ needs.resolve-environment.outputs.environment }}
|
|
213
|
+
if: vars.SIBLING_DEPLOY_ENABLED == 'true'
|
|
214
|
+
steps:
|
|
215
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
216
|
+
- name: Core-version preflight
|
|
217
|
+
# scripts/core-version-preflight.sh does the actual check; see its
|
|
218
|
+
# own header for the full reasoning (why NOT biffo.sibling.json's
|
|
219
|
+
# template_version, why absence fails closed rather than being
|
|
220
|
+
# treated as satisfied — the #1363 fail-open class, same as
|
|
221
|
+
# preflight-core-revision above).
|
|
222
|
+
#
|
|
223
|
+
# CORE_MIN_TEMPLATE_VERSION is how THIS sibling declares which core
|
|
224
|
+
# template version its code depends on — set by hand as a
|
|
225
|
+
# per-environment repo variable, the same way CORE_MIN_ROUTE_REVISION
|
|
226
|
+
# is set: after merging a core PR your code needs, record the
|
|
227
|
+
# version /api/v1/health reports for that deploy here. Unset means
|
|
228
|
+
# "this sibling depends on no specific core template version" and
|
|
229
|
+
# the check is skipped — the correct default for a sibling that has
|
|
230
|
+
# never declared a dependency, not a fail-open.
|
|
231
|
+
env:
|
|
232
|
+
CORE_MIN_TEMPLATE_VERSION: ${{ vars.CORE_MIN_TEMPLATE_VERSION }}
|
|
233
|
+
CORE_HEALTH_URL: ${{ vars.CORE_API_URL }}/api/v1/health
|
|
234
|
+
run: sh scripts/core-version-preflight.sh
|
|
235
|
+
|
|
193
236
|
deploy-app:
|
|
194
237
|
name: Deploy app
|
|
195
|
-
needs: [resolve-environment, deploy-infra, preflight-core-revision]
|
|
238
|
+
needs: [resolve-environment, deploy-infra, preflight-core-revision, preflight-core-version]
|
|
196
239
|
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
197
240
|
environment: ${{ needs.resolve-environment.outputs.environment }}
|
|
198
241
|
steps:
|
|
@@ -394,7 +437,14 @@ jobs:
|
|
|
394
437
|
# and "never configured" are different facts and only one of them is a defect:
|
|
395
438
|
deploy-status:
|
|
396
439
|
name: Deploy status
|
|
397
|
-
needs:
|
|
440
|
+
needs:
|
|
441
|
+
[
|
|
442
|
+
resolve-environment,
|
|
443
|
+
deploy-infra,
|
|
444
|
+
preflight-core-revision,
|
|
445
|
+
preflight-core-version,
|
|
446
|
+
deploy-app,
|
|
447
|
+
]
|
|
398
448
|
if: always()
|
|
399
449
|
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
400
450
|
steps:
|
|
@@ -403,9 +453,10 @@ jobs:
|
|
|
403
453
|
ENABLED: ${{ vars.SIBLING_DEPLOY_ENABLED }}
|
|
404
454
|
INFRA: ${{ needs.deploy-infra.result }}
|
|
405
455
|
PREFLIGHT: ${{ needs.preflight-core-revision.result }}
|
|
456
|
+
VERSION_PREFLIGHT: ${{ needs.preflight-core-version.result }}
|
|
406
457
|
APP: ${{ needs.deploy-app.result }}
|
|
407
458
|
run: |
|
|
408
|
-
echo "SIBLING_DEPLOY_ENABLED='${ENABLED}' deploy-infra=${INFRA} preflight-core-revision=${PREFLIGHT} deploy-app=${APP}"
|
|
459
|
+
echo "SIBLING_DEPLOY_ENABLED='${ENABLED}' deploy-infra=${INFRA} preflight-core-revision=${PREFLIGHT} preflight-core-version=${VERSION_PREFLIGHT} deploy-app=${APP}"
|
|
409
460
|
|
|
410
461
|
if [ "${ENABLED}" = "false" ]; then
|
|
411
462
|
echo "::notice::Deploys are deliberately disabled for this repo (SIBLING_DEPLOY_ENABLED=false). Nothing was deployed, by choice."
|
|
@@ -418,14 +469,15 @@ jobs:
|
|
|
418
469
|
fi
|
|
419
470
|
|
|
420
471
|
# A real failure already makes the run red for the right reason, and
|
|
421
|
-
# `deploy-app` needs `deploy-infra
|
|
422
|
-
# so a failed infra job or
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
#
|
|
428
|
-
|
|
472
|
+
# `deploy-app` needs `deploy-infra`, `preflight-core-revision` AND
|
|
473
|
+
# `preflight-core-version` — so a failed infra job or either
|
|
474
|
+
# preflight that blocked a stale sibling SKIPS the app job.
|
|
475
|
+
# Reporting that skip as "deployed nothing" would bury the actual
|
|
476
|
+
# cause (visible on the failed job itself, e.g.
|
|
477
|
+
# core-revision-preflight.sh's or core-version-preflight.sh's own
|
|
478
|
+
# ::error:: naming the required and actual values, #1604/#1605)
|
|
479
|
+
# under a second, vaguer error. Defer to the real one.
|
|
480
|
+
case "${INFRA}:${PREFLIGHT}:${VERSION_PREFLIGHT}:${APP}" in
|
|
429
481
|
*failure*|*cancelled*)
|
|
430
482
|
echo "A deploy job failed or was cancelled; that failure is the run's result. Not adding a second error."
|
|
431
483
|
exit 0
|
|
@@ -434,7 +486,7 @@ jobs:
|
|
|
434
486
|
|
|
435
487
|
# Enabled — the jobs were meant to run. 'skipped' here means a gate
|
|
436
488
|
# upstream of them suppressed the work while the run still went green.
|
|
437
|
-
for pair in "deploy-infra:${INFRA}" "preflight-core-revision:${PREFLIGHT}" "deploy-app:${APP}"; do
|
|
489
|
+
for pair in "deploy-infra:${INFRA}" "preflight-core-revision:${PREFLIGHT}" "preflight-core-version:${VERSION_PREFLIGHT}" "deploy-app:${APP}"; do
|
|
438
490
|
job="${pair%%:*}"
|
|
439
491
|
result="${pair#*:}"
|
|
440
492
|
if [ "${result}" = "skipped" ]; then
|
|
@@ -443,4 +495,4 @@ jobs:
|
|
|
443
495
|
fi
|
|
444
496
|
done
|
|
445
497
|
|
|
446
|
-
echo "Deploy jobs ran (infra=${INFRA}, preflight=${PREFLIGHT}, app=${APP})."
|
|
498
|
+
echo "Deploy jobs ran (infra=${INFRA}, preflight=${PREFLIGHT}, version-preflight=${VERSION_PREFLIGHT}, app=${APP})."
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Core-version preflight (#1605).
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A sibling can deploy against a core instance whose TEMPLATE version is
|
|
8
|
+
# older than the sibling's code needs — a different failure from #1604's
|
|
9
|
+
# route-ordering race. #1604 asks "has core shipped the specific route my
|
|
10
|
+
# code depends on RIGHT NOW"; this asks "is the instance running at least
|
|
11
|
+
# the core template capability my code was written against". The two are
|
|
12
|
+
# deliberately separate scripts and separate deploy jobs, because they read
|
|
13
|
+
# different signals and fail for different reasons — conflating them would
|
|
14
|
+
# make either failure's message describe the wrong problem. This script does
|
|
15
|
+
# NOT address, and is not a substitute for, #1604's deploy-ordering race.
|
|
16
|
+
#
|
|
17
|
+
# ## The signal
|
|
18
|
+
#
|
|
19
|
+
# `services/api/src/api/routers/health.py` returns `core_version()`, baked
|
|
20
|
+
# in at package time by `scripts/resolve-core-version.sh`, and is publicly
|
|
21
|
+
# reachable with no credential at `/api/v1/health` on every deployed
|
|
22
|
+
# instance (`{"status":"ok","version":"0.287.12"}`). Nothing new is
|
|
23
|
+
# published for this script — it reads a signal that already exists.
|
|
24
|
+
#
|
|
25
|
+
# ## Why NOT biffo.sibling.json's `template_version`
|
|
26
|
+
#
|
|
27
|
+
# The obvious-looking home for a declared floor is the sibling's own
|
|
28
|
+
# `template_version` field. It is the wrong field: ADR-0007 states it is
|
|
29
|
+
# stamped ONCE at scaffold time from `getLatestCoreVersion()`, is
|
|
30
|
+
# "visibility only", and explicitly "nothing reads or compares the field
|
|
31
|
+
# yet" — it is a backward PROVENANCE stamp that only ever falls further
|
|
32
|
+
# behind, not a forward REQUIREMENT that rises as a sibling adopts newer
|
|
33
|
+
# core capability. Measured live across all five real siblings at the time
|
|
34
|
+
# #1605 was picked up: four had no `template_version` at all and the fifth
|
|
35
|
+
# was ~80 minor versions behind the running core — reusing it as a floor
|
|
36
|
+
# would be inert for four siblings and trivially satisfied by the fifth.
|
|
37
|
+
# Mirroring #1604's already-proven shape instead: a per-environment GitHub
|
|
38
|
+
# Actions repo variable the sibling sets by hand, the same way
|
|
39
|
+
# CORE_MIN_ROUTE_REVISION is set — no JSON schema change, no ADR amendment,
|
|
40
|
+
# no new document.
|
|
41
|
+
#
|
|
42
|
+
# ## Fail-closed (the #1363 class)
|
|
43
|
+
#
|
|
44
|
+
# A sibling that declares no minimum has nothing to check — that is a
|
|
45
|
+
# legitimate "I depend on nothing beyond whatever core happens to be
|
|
46
|
+
# running" state, and it passes with a notice. Once a minimum IS declared,
|
|
47
|
+
# absence of a valid version in the health response — an unreachable
|
|
48
|
+
# instance, a non-packaged deployment reporting the literal string
|
|
49
|
+
# "unknown" (health.py's own documented fallback), a malformed body — must
|
|
50
|
+
# never read as "satisfied". It fails, and says WHICH: "missing/unreachable"
|
|
51
|
+
# and "present but behind" are different facts, and a caller reading only
|
|
52
|
+
# the exit code must not be able to mistake one for the other from the log.
|
|
53
|
+
#
|
|
54
|
+
# ## Version comparison
|
|
55
|
+
#
|
|
56
|
+
# Both versions are dotted non-negative-integer strings (e.g. "0.287.12"),
|
|
57
|
+
# the shape every `core-v*` tag and every `biffo.core.json` `.version` takes
|
|
58
|
+
# (see resolve-core-version.sh). Compared segment-by-segment, numerically,
|
|
59
|
+
# left to right, with a missing trailing segment on either side treated as
|
|
60
|
+
# 0 — so "0.287" and "0.287.0" compare equal, and "0.9" is correctly less
|
|
61
|
+
# than "0.10". This is deliberately NOT full semver (no pre-release/build
|
|
62
|
+
# metadata) because nothing in this estate's version strings ever carries
|
|
63
|
+
# either.
|
|
64
|
+
#
|
|
65
|
+
# Exit codes:
|
|
66
|
+
# 0 = no minimum declared (nothing to check), or the instance is at/past it.
|
|
67
|
+
# 1 = the instance is behind the declared minimum, its health endpoint is
|
|
68
|
+
# unreachable, or its reported version could not be parsed. Every one
|
|
69
|
+
# of these is "not verified as safe" and none may be treated as a pass.
|
|
70
|
+
#
|
|
71
|
+
# ## Usage
|
|
72
|
+
#
|
|
73
|
+
# CORE_MIN_TEMPLATE_VERSION=0.250.0 \
|
|
74
|
+
# CORE_HEALTH_URL=https://dev.example.com/api/v1/health \
|
|
75
|
+
# sh scripts/core-version-preflight.sh
|
|
76
|
+
#
|
|
77
|
+
# CORE_MIN_TEMPLATE_VERSION — the minimum core template version this
|
|
78
|
+
# sibling's code needs. Set as a per-environment
|
|
79
|
+
# repo variable (vars.CORE_MIN_TEMPLATE_VERSION),
|
|
80
|
+
# the same way CORE_MIN_ROUTE_REVISION is set by
|
|
81
|
+
# hand. Unset/empty means "no floor declared"
|
|
82
|
+
# and the check is skipped.
|
|
83
|
+
# CORE_HEALTH_URL — where to fetch the instance's health document,
|
|
84
|
+
# typically "${CORE_API_URL}/api/v1/health".
|
|
85
|
+
# Required whenever a minimum is declared.
|
|
86
|
+
# PREFLIGHT_CURL — override the curl binary/wrapper (tests use
|
|
87
|
+
# this to point at a stub).
|
|
88
|
+
#
|
|
89
|
+
# Run this file's own tests: sh scripts/core-version-preflight.test.sh
|
|
90
|
+
|
|
91
|
+
set -u
|
|
92
|
+
|
|
93
|
+
CURL=${PREFLIGHT_CURL:-curl}
|
|
94
|
+
|
|
95
|
+
MIN=${CORE_MIN_TEMPLATE_VERSION:-}
|
|
96
|
+
if [ -z "$MIN" ]; then
|
|
97
|
+
# Loud on purpose, matching core-revision-preflight.sh's own reasoning: a
|
|
98
|
+
# green "Preflight — core template version" step must never look
|
|
99
|
+
# identical whether it actually checked something or checked nothing.
|
|
100
|
+
MSG="core-version-preflight: preflight did NOT run — CORE_MIN_TEMPLATE_VERSION is not set, so this sibling has declared no core-template-version dependency. This is 'nothing was checked', not 'core was checked and is ready'; set CORE_MIN_TEMPLATE_VERSION once this sibling depends on a specific core template version."
|
|
101
|
+
echo "::notice::${MSG}"
|
|
102
|
+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
103
|
+
echo "${MSG}" >> "$GITHUB_STEP_SUMMARY"
|
|
104
|
+
fi
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
is_version() {
|
|
109
|
+
printf '%s' "$1" | grep -Eq '^[0-9]+(\.[0-9]+)*$'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if ! is_version "$MIN"; then
|
|
113
|
+
echo "::error::core-version-preflight: CORE_MIN_TEMPLATE_VERSION='$MIN' is not a dotted non-negative-integer version (e.g. '0.250.0')." >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
URL=${CORE_HEALTH_URL:-}
|
|
118
|
+
if [ -z "$URL" ]; then
|
|
119
|
+
echo "::error::core-version-preflight: CORE_MIN_TEMPLATE_VERSION=$MIN is declared but CORE_HEALTH_URL is not set — cannot fetch the instance's health document to check it against. Refusing to proceed rather than assuming core is ready." >&2
|
|
120
|
+
exit 1
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
BODY_FILE=/tmp/core-version-preflight-body.$$
|
|
124
|
+
ERR_FILE=/tmp/core-version-preflight-err.$$
|
|
125
|
+
trap 'rm -f "$BODY_FILE" "$ERR_FILE"' EXIT
|
|
126
|
+
|
|
127
|
+
HTTP_CODE=$("$CURL" -sS --max-time 15 --retry 2 --retry-delay 2 \
|
|
128
|
+
-o "$BODY_FILE" -w '%{http_code}' "$URL" 2>"$ERR_FILE")
|
|
129
|
+
RC=$?
|
|
130
|
+
ERR=$(cat "$ERR_FILE" 2>/dev/null)
|
|
131
|
+
|
|
132
|
+
if [ "$RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
|
133
|
+
echo "::error::core-version-preflight: no health response available at $URL (curl exit $RC, http ${HTTP_CODE:-none}${ERR:+, $ERR}). An absent or unreachable health endpoint is treated as NOT satisfied — this is expected on a down instance, a network issue, or a not-yet-deployed core — and is a DIFFERENT fact from 'core is behind version $MIN'. Refusing to deploy rather than assuming core is ready." >&2
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
ACTUAL=$(command -v jq >/dev/null 2>&1 && jq -r 'if (.version | type) == "string" then .version else empty end' "$BODY_FILE" 2>/dev/null)
|
|
138
|
+
|
|
139
|
+
if [ -z "${ACTUAL:-}" ] || ! is_version "$ACTUAL"; then
|
|
140
|
+
# Deliberately covers health.py's own documented fallback: outside a
|
|
141
|
+
# packaged deployment it returns the literal string "unknown", which is
|
|
142
|
+
# not a version and must fail the same way a missing/malformed body does
|
|
143
|
+
# — never be silently treated as satisfied.
|
|
144
|
+
echo "::error::core-version-preflight: health document at $URL did not contain a valid dotted version in 'version' (got '${ACTUAL:-<empty>}'). Treating a malformed or unknown version the same as a missing one — refusing to deploy." >&2
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
version_ge() {
|
|
149
|
+
# Prints "1" if $1 >= $2, "0" otherwise — dotted numeric segments compared
|
|
150
|
+
# left to right, missing trailing segments on either side treated as 0.
|
|
151
|
+
awk -v a="$1" -v b="$2" '
|
|
152
|
+
BEGIN {
|
|
153
|
+
na = split(a, pa, ".")
|
|
154
|
+
nb = split(b, pb, ".")
|
|
155
|
+
n = (na > nb) ? na : nb
|
|
156
|
+
for (i = 1; i <= n; i++) {
|
|
157
|
+
x = (i <= na) ? pa[i] + 0 : 0
|
|
158
|
+
y = (i <= nb) ? pb[i] + 0 : 0
|
|
159
|
+
if (x > y) { print "1"; exit }
|
|
160
|
+
if (x < y) { print "0"; exit }
|
|
161
|
+
}
|
|
162
|
+
print "1"
|
|
163
|
+
}
|
|
164
|
+
'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if [ "$(version_ge "$ACTUAL" "$MIN")" = "1" ]; then
|
|
168
|
+
echo "core-version-preflight: OK — core is at version $ACTUAL, this sibling needs at least $MIN."
|
|
169
|
+
exit 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
echo "::error::core-version-preflight: core is at version $ACTUAL, but this sibling needs at least $MIN. The instance has not upgraded to the core template version this sibling depends on yet — refusing to deploy ahead of it." >&2
|
|
173
|
+
exit 1
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves core-version-preflight.sh (#1605) actually blocks a sibling from
|
|
4
|
+
# deploying against an instance whose core template version is too old,
|
|
5
|
+
# passes it once the instance has caught up, and fails CLOSED — never open
|
|
6
|
+
# — when the health document is missing, unreachable, or reports the
|
|
7
|
+
# "unknown" version health.py falls back to outside a packaged deployment.
|
|
8
|
+
#
|
|
9
|
+
# Stubs `curl` on PATH so this needs no network and no live deployment.
|
|
10
|
+
#
|
|
11
|
+
# Run: sh scripts/core-version-preflight.test.sh
|
|
12
|
+
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
16
|
+
TARGET="$SCRIPT_DIR/core-version-preflight.sh"
|
|
17
|
+
STUB_DIR=$(mktemp -d)
|
|
18
|
+
OUT_FILE=/tmp/core-version-preflight-test-out.$$
|
|
19
|
+
trap 'rm -rf "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
20
|
+
|
|
21
|
+
FAILURES=0
|
|
22
|
+
URL="https://dev.example.com/api/v1/health"
|
|
23
|
+
|
|
24
|
+
# _run <env assignments...> -- runs the target with PREFLIGHT_CURL pointed at
|
|
25
|
+
# the stub, capturing combined output to $OUT_FILE and the exit code into
|
|
26
|
+
# $LAST_RC. NOT run inside a subshell/command-substitution, matching
|
|
27
|
+
# core-revision-preflight.test.sh's own reasoning: an assertion made inside
|
|
28
|
+
# `out=$(...)` runs FAILURES++ in a subshell that never reaches the parent,
|
|
29
|
+
# so a real regression could not fail the test.
|
|
30
|
+
_run() {
|
|
31
|
+
env "$@" sh -c "PREFLIGHT_CURL='$STUB_DIR/curl' sh '$TARGET'" >"$OUT_FILE" 2>&1
|
|
32
|
+
LAST_RC=$?
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_assert_exit() {
|
|
36
|
+
# _assert_exit <scenario-name> <expected-exit>
|
|
37
|
+
name=$1
|
|
38
|
+
expected=$2
|
|
39
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
40
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
41
|
+
else
|
|
42
|
+
echo "FAIL: $name — expected exit $expected, got $LAST_RC"
|
|
43
|
+
echo "--- output ---"
|
|
44
|
+
cat "$OUT_FILE"
|
|
45
|
+
echo "--------------"
|
|
46
|
+
FAILURES=$((FAILURES + 1))
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_assert_output_contains() {
|
|
51
|
+
# _assert_output_contains <scenario-name> <needle>
|
|
52
|
+
name=$1
|
|
53
|
+
needle=$2
|
|
54
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
55
|
+
echo "PASS: $name mentions '$needle'"
|
|
56
|
+
else
|
|
57
|
+
echo "FAIL: $name — expected output to mention '$needle'"
|
|
58
|
+
echo "--- output ---"
|
|
59
|
+
cat "$OUT_FILE"
|
|
60
|
+
echo "--------------"
|
|
61
|
+
FAILURES=$((FAILURES + 1))
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# --- Stub curl factory --------------------------------------------------
|
|
66
|
+
# Writes a stub that returns a fixed http status + body for the one URL this
|
|
67
|
+
# script ever fetches, or simulates a transport failure.
|
|
68
|
+
_write_stub_ok() {
|
|
69
|
+
version=$1
|
|
70
|
+
cat > "$STUB_DIR/curl" <<STUB
|
|
71
|
+
#!/usr/bin/env sh
|
|
72
|
+
outfile=""
|
|
73
|
+
prev=""
|
|
74
|
+
for a in "\$@"; do
|
|
75
|
+
case "\$prev" in
|
|
76
|
+
-o) outfile="\$a" ;;
|
|
77
|
+
esac
|
|
78
|
+
prev="\$a"
|
|
79
|
+
done
|
|
80
|
+
printf '{"status": "ok", "version": "$version"}' > "\$outfile"
|
|
81
|
+
printf '200'
|
|
82
|
+
exit 0
|
|
83
|
+
STUB
|
|
84
|
+
chmod +x "$STUB_DIR/curl"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_write_stub_unreachable() {
|
|
88
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
89
|
+
#!/usr/bin/env sh
|
|
90
|
+
echo "curl: (6) Could not resolve host: dev.example.com" >&2
|
|
91
|
+
exit 6
|
|
92
|
+
STUB
|
|
93
|
+
chmod +x "$STUB_DIR/curl"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_write_stub_404() {
|
|
97
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
98
|
+
#!/usr/bin/env sh
|
|
99
|
+
outfile=""
|
|
100
|
+
prev=""
|
|
101
|
+
for a in "$@"; do
|
|
102
|
+
case "$prev" in
|
|
103
|
+
-o) outfile="$a" ;;
|
|
104
|
+
esac
|
|
105
|
+
prev="$a"
|
|
106
|
+
done
|
|
107
|
+
printf 'Not Found' > "$outfile"
|
|
108
|
+
printf '404'
|
|
109
|
+
exit 0
|
|
110
|
+
STUB
|
|
111
|
+
chmod +x "$STUB_DIR/curl"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_write_stub_never_called() {
|
|
115
|
+
# A curl invocation in this scenario is itself the bug — the script must
|
|
116
|
+
# short-circuit before ever fetching.
|
|
117
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
118
|
+
#!/usr/bin/env sh
|
|
119
|
+
echo "curl invoked when it should not have been" >&2
|
|
120
|
+
exit 99
|
|
121
|
+
STUB
|
|
122
|
+
chmod +x "$STUB_DIR/curl"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# 1. No CORE_MIN_TEMPLATE_VERSION declared at all — this sibling depends on
|
|
126
|
+
# no specific core template version. Must pass WITHOUT even invoking
|
|
127
|
+
# curl, and must say so LOUDLY: a GitHub Actions ::notice:: annotation
|
|
128
|
+
# (not a bare echo a reader would have to open the step log to find),
|
|
129
|
+
# and never wording that could be mistaken for "checked and fine".
|
|
130
|
+
_write_stub_never_called
|
|
131
|
+
_run -u CORE_MIN_TEMPLATE_VERSION -u CORE_HEALTH_URL --
|
|
132
|
+
_assert_exit "no minimum declared" 0
|
|
133
|
+
_assert_output_contains "no minimum declared — is a ::notice:: annotation" "::notice::"
|
|
134
|
+
_assert_output_contains "no minimum declared — says preflight did not run" "did NOT run"
|
|
135
|
+
|
|
136
|
+
# 1b. Same scenario, but with GITHUB_STEP_SUMMARY set as it is in a real
|
|
137
|
+
# GitHub Actions run — the skip must ALSO land in the job summary.
|
|
138
|
+
SUMMARY_FILE=/tmp/core-version-preflight-test-summary.$$
|
|
139
|
+
: > "$SUMMARY_FILE"
|
|
140
|
+
_write_stub_never_called
|
|
141
|
+
_run -u CORE_MIN_TEMPLATE_VERSION -u CORE_HEALTH_URL -- GITHUB_STEP_SUMMARY="$SUMMARY_FILE"
|
|
142
|
+
_assert_exit "no minimum declared, with GITHUB_STEP_SUMMARY set" 0
|
|
143
|
+
if grep -qF "did NOT run" "$SUMMARY_FILE" 2>/dev/null; then
|
|
144
|
+
echo "PASS: no minimum declared — writes to \$GITHUB_STEP_SUMMARY"
|
|
145
|
+
else
|
|
146
|
+
echo "FAIL: no minimum declared — expected \$GITHUB_STEP_SUMMARY ($SUMMARY_FILE) to mention 'did NOT run'"
|
|
147
|
+
echo "--- summary file ---"
|
|
148
|
+
cat "$SUMMARY_FILE" 2>/dev/null
|
|
149
|
+
echo "--------------------"
|
|
150
|
+
FAILURES=$((FAILURES + 1))
|
|
151
|
+
fi
|
|
152
|
+
rm -f "$SUMMARY_FILE"
|
|
153
|
+
|
|
154
|
+
# 2. Sibling requires a version core HAS reached (core is AHEAD) — passes.
|
|
155
|
+
_write_stub_ok "0.287.12"
|
|
156
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
157
|
+
_assert_exit "core ahead of required version (0.250.0 <= 0.287.12)" 0
|
|
158
|
+
_assert_output_contains "core ahead — names actual" "version 0.287.12"
|
|
159
|
+
|
|
160
|
+
# 2b. Exact match — core is AT the required version — passes.
|
|
161
|
+
_write_stub_ok "0.250.0"
|
|
162
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
163
|
+
_assert_exit "core exactly at required version" 0
|
|
164
|
+
|
|
165
|
+
# 2c. Lexicographic trap: "0.9" must compare LESS than "0.10" numerically,
|
|
166
|
+
# not greater as a naive string compare would report.
|
|
167
|
+
_write_stub_ok "0.9.0"
|
|
168
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.10.0 CORE_HEALTH_URL="$URL"
|
|
169
|
+
_assert_exit "0.9.0 is numerically behind 0.10.0 (string compare would say ahead)" 1
|
|
170
|
+
|
|
171
|
+
# 2d. Missing trailing segment treated as 0 — "0.287" == "0.287.0".
|
|
172
|
+
_write_stub_ok "0.287"
|
|
173
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.287.0 CORE_HEALTH_URL="$URL"
|
|
174
|
+
_assert_exit "0.287 satisfies a 0.287.0 floor (missing segment = 0)" 0
|
|
175
|
+
|
|
176
|
+
# 3. Sibling requires a version core has NOT reached — fails, naming both
|
|
177
|
+
# the required and actual versions.
|
|
178
|
+
_write_stub_ok "0.208.1"
|
|
179
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
180
|
+
_assert_exit "core behind required version" 1
|
|
181
|
+
_assert_output_contains "core behind — names actual" "version 0.208.1"
|
|
182
|
+
_assert_output_contains "core behind — names required" "at least 0.250.0"
|
|
183
|
+
|
|
184
|
+
# 4. No health document available at all (transport failure) — fails
|
|
185
|
+
# CLOSED, and says the health endpoint was missing rather than that core
|
|
186
|
+
# is behind.
|
|
187
|
+
_write_stub_unreachable
|
|
188
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
189
|
+
_assert_exit "health endpoint unreachable" 1
|
|
190
|
+
_assert_output_contains "health endpoint unreachable — says missing, not behind" "no health response available"
|
|
191
|
+
|
|
192
|
+
# 5. Health endpoint 404s — fails CLOSED, same "missing" message as
|
|
193
|
+
# scenario 4, never "core is behind".
|
|
194
|
+
_write_stub_404
|
|
195
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
196
|
+
_assert_exit "health endpoint 404" 1
|
|
197
|
+
_assert_output_contains "health endpoint 404 — says missing" "no health response available"
|
|
198
|
+
|
|
199
|
+
# 6. Instance reports the literal "unknown" version — health.py's own
|
|
200
|
+
# documented fallback outside a packaged deployment. Must fail CLOSED
|
|
201
|
+
# exactly like a malformed/missing body, never be treated as satisfied.
|
|
202
|
+
_write_stub_ok "unknown"
|
|
203
|
+
_run CORE_MIN_TEMPLATE_VERSION=0.250.0 CORE_HEALTH_URL="$URL"
|
|
204
|
+
_assert_exit "instance reports 'unknown' version" 1
|
|
205
|
+
_assert_output_contains "'unknown' version — treated as malformed, not satisfied" "did not contain a valid dotted version"
|
|
206
|
+
|
|
207
|
+
# 7. A minimum is declared but CORE_HEALTH_URL is not set — must fail
|
|
208
|
+
# without invoking curl (misconfiguration, not "core is ready").
|
|
209
|
+
_write_stub_never_called
|
|
210
|
+
_run -u CORE_HEALTH_URL -- CORE_MIN_TEMPLATE_VERSION=0.250.0
|
|
211
|
+
_assert_exit "minimum declared but URL unset" 1
|
|
212
|
+
|
|
213
|
+
echo
|
|
214
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
215
|
+
echo "core-version-preflight.test.sh: all checks passed."
|
|
216
|
+
exit 0
|
|
217
|
+
else
|
|
218
|
+
echo "core-version-preflight.test.sh: $FAILURES check(s) failed."
|
|
219
|
+
exit 1
|
|
220
|
+
fi
|