@biffo/cli 0.301.14 → 0.301.16

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.
@@ -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: [resolve-environment, deploy-infra, preflight-core-revision, deploy-app]
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` AND `preflight-core-revision` —
422
- # so a failed infra job or a preflight that blocked a stale sibling
423
- # SKIPS the app job. Reporting that skip as "deployed nothing" would
424
- # bury the actual cause (visible on the failed job itself, e.g.
425
- # core-revision-preflight.sh's own ::error:: naming the required
426
- # and actual revisions, #1604) under a second, vaguer error. Defer
427
- # to the real one.
428
- case "${INFRA}:${PREFLIGHT}:${APP}" in
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.301.14",
3
+ "version": "0.301.16",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",