@biffo/cli 0.292.0 → 0.294.0
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 +15 -0
- package/_skeletons/plugin-template/scripts/core-revision-preflight.sh +165 -0
- package/_skeletons/plugin-template/scripts/core-revision-preflight.test.sh +203 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +11 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +63 -9
- package/_skeletons/sibling-template/scripts/core-revision-preflight.sh +165 -0
- package/_skeletons/sibling-template/scripts/core-revision-preflight.test.sh +203 -0
- package/package.json +1 -1
|
@@ -85,6 +85,21 @@ jobs:
|
|
|
85
85
|
# measure.
|
|
86
86
|
- name: Error-branch coverage
|
|
87
87
|
run: uv run python scripts/error_branch_coverage.py --check --coverage coverage.json
|
|
88
|
+
- name: Core-revision preflight — self-test
|
|
89
|
+
if: ${{ !cancelled() }}
|
|
90
|
+
# scripts/core-revision-preflight.sh (biffo-template#1604) is
|
|
91
|
+
# distributed here too, verbatim, via shared-files.json's `files` —
|
|
92
|
+
# but a plugin repo has no deploy.yml and nothing that mounts an
|
|
93
|
+
# independently-deployed service ahead of core, so it is never
|
|
94
|
+
# invoked for a REAL preflight in this repo the way the sibling
|
|
95
|
+
# skeleton's deploy.yml invokes it. This is the caller that keeps it
|
|
96
|
+
# off the #1413 zero-caller list anyway: it proves the script itself
|
|
97
|
+
# still blocks a stale core, passes a current one, and fails CLOSED
|
|
98
|
+
# when the digest is missing, against a stubbed curl — the same
|
|
99
|
+
# governance-plumbing reasoning js-dependency-audit.sh/
|
|
100
|
+
# py-dependency-audit.sh already carry as inert-but-verified copies
|
|
101
|
+
# in a Python-only or JS-only sibling.
|
|
102
|
+
run: sh scripts/core-revision-preflight.test.sh
|
|
88
103
|
- uses: codecov/codecov-action@v5
|
|
89
104
|
if: always()
|
|
90
105
|
with:
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Core-revision preflight (#1604).
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A sibling's deploy is structurally incapable of seeing core's deploy state:
|
|
8
|
+
# `resolve-environment -> deploy-infra -> deploy-app` runs with no step that
|
|
9
|
+
# reads anything about core, and its only post-deploy proof is its OWN Lambda
|
|
10
|
+
# health and its OWN CDN route. Merge ordering does not fix this either — in
|
|
11
|
+
# the recorded incident the core PR had already merged and the core deploy
|
|
12
|
+
# simply sat in the runner queue while the sibling went live first, serving
|
|
13
|
+
# requests against routes core had not shipped yet.
|
|
14
|
+
#
|
|
15
|
+
# The owner's decision (#1604): core publishes a DIGEST of its route table to
|
|
16
|
+
# the existing public `.well-known/` channel — a monotonic revision counter
|
|
17
|
+
# paired with a content hash, never the route surface itself (`/openapi.json`
|
|
18
|
+
# stays 401-gated). This script is the consumer half: it asks "is core at
|
|
19
|
+
# least at the revision I need", using ONLY that published digest, run
|
|
20
|
+
# BEFORE `deploy-app` so a sibling that would go live ahead of core never
|
|
21
|
+
# gets that far.
|
|
22
|
+
#
|
|
23
|
+
# ## Why "revision", not the hash
|
|
24
|
+
#
|
|
25
|
+
# The hash proves the route table is DIFFERENT from some prior observation;
|
|
26
|
+
# it cannot prove "at or past", because "different" has no direction. The
|
|
27
|
+
# published `revision` is core's `git rev-list --count HEAD` at deploy time
|
|
28
|
+
# (scripts/route-table-digest.py) — the number of commits reachable from the
|
|
29
|
+
# deployed commit, not a counter that increments once per deploy. That
|
|
30
|
+
# distinction is the whole point: a deploy counter goes up on every run
|
|
31
|
+
# regardless of what shipped, so a ROLLBACK (a new deploy of an OLDER
|
|
32
|
+
# commit) would report a revision higher than the one that actually had the
|
|
33
|
+
# routes a sibling needs — a false "yes, at or past" fired exactly when the
|
|
34
|
+
# true answer is "no". A commit count is tied to the code's real position in
|
|
35
|
+
# history: redeploying the same commit reports the same revision (correctly
|
|
36
|
+
# "at least as new" — an equality, not a regression); rolling back to an
|
|
37
|
+
# older commit reports a LOWER revision, because that commit genuinely has
|
|
38
|
+
# fewer ancestors. This script only ever compares that one integer.
|
|
39
|
+
#
|
|
40
|
+
# ## This script's monotonicity guarantee is borrowed, not owned
|
|
41
|
+
#
|
|
42
|
+
# `git rev-list --count HEAD` is only monotonic/collision-free along one
|
|
43
|
+
# continuous, non-rewritten history. If core's `dev`/`staging`/`main` ever
|
|
44
|
+
# allowed force pushes or a non-linear history, a rewritten branch could
|
|
45
|
+
# report a LOWER commit count for a commit that is, in reality, later —
|
|
46
|
+
# silently undermining the one property this whole mechanism depends on,
|
|
47
|
+
# with no signal anywhere in this script or its caller. Checked directly
|
|
48
|
+
# against the live `keiranholloway/biffo-platform` repo during #1635's
|
|
49
|
+
# prosecution: `allow_force_pushes: false`, `required_linear_history: true`
|
|
50
|
+
# and `enforce_admins: true` are all set on every one of those branches
|
|
51
|
+
# today, so the guarantee currently holds — but nothing in this script (or
|
|
52
|
+
# in scripts/route-table-digest.py, which computes the number on the
|
|
53
|
+
# publishing side) asserts that, or would notice if it changed. Deliberately
|
|
54
|
+
# NOT asserted with a live GitHub API call here: this script runs in every
|
|
55
|
+
# sibling's deploy job, on every deploy, and a branch-protection check would
|
|
56
|
+
# add a second network dependency and a second way to fail closed for a
|
|
57
|
+
# property that is core's to guarantee, not each sibling's to re-verify on
|
|
58
|
+
# every run. If this ever needs enforcing rather than documenting, it
|
|
59
|
+
# belongs as a periodic check against the CORE repo (once, not once per
|
|
60
|
+
# sibling deploy) — not bolted onto this script.
|
|
61
|
+
#
|
|
62
|
+
# ## Fail-closed (the #1363 class)
|
|
63
|
+
#
|
|
64
|
+
# A sibling that declares no minimum has nothing to check — that is a
|
|
65
|
+
# legitimate "I depend on nothing new from core" state, and it passes with a
|
|
66
|
+
# notice. But once a minimum IS declared, absence of the digest — an older
|
|
67
|
+
# core, a failed publish, a CDN cache miss — must never read as "satisfied".
|
|
68
|
+
# It fails, and says WHICH: "missing/unreachable" and "present but behind"
|
|
69
|
+
# are different facts, and a caller reading only the exit code must not be
|
|
70
|
+
# able to mistake one for the other from the log.
|
|
71
|
+
#
|
|
72
|
+
# Exit codes:
|
|
73
|
+
# 0 = no minimum declared (nothing to check), or core is at/past it.
|
|
74
|
+
# 1 = core is behind the declared minimum, the digest is missing or
|
|
75
|
+
# unreachable, or the digest could not be parsed. Every one of these
|
|
76
|
+
# is "not verified as safe" and none may be treated as a pass.
|
|
77
|
+
#
|
|
78
|
+
# ## Usage
|
|
79
|
+
#
|
|
80
|
+
# CORE_MIN_ROUTE_REVISION=42 \
|
|
81
|
+
# CORE_ROUTE_REVISION_URL=https://dev.example.com/.well-known/route-revision.json \
|
|
82
|
+
# sh scripts/core-revision-preflight.sh
|
|
83
|
+
#
|
|
84
|
+
# CORE_MIN_ROUTE_REVISION — the minimum core revision this sibling's code
|
|
85
|
+
# needs. Set as a per-environment repo variable
|
|
86
|
+
# (vars.CORE_MIN_ROUTE_REVISION), the same way a
|
|
87
|
+
# version pin is set by hand: after merging the
|
|
88
|
+
# core PR your sibling code depends on, read the
|
|
89
|
+
# `revision` core published for that deploy and
|
|
90
|
+
# record it here. Unset/empty means "no new
|
|
91
|
+
# dependency" and the check is skipped.
|
|
92
|
+
# CORE_ROUTE_REVISION_URL — where to fetch the digest, typically
|
|
93
|
+
# "${CORE_PORTAL_URL}/.well-known/route-revision.json".
|
|
94
|
+
# Required whenever a minimum is declared.
|
|
95
|
+
# PREFLIGHT_CURL — override the curl binary/wrapper (tests use
|
|
96
|
+
# this to point at a stub).
|
|
97
|
+
#
|
|
98
|
+
# Run this file's own tests: sh scripts/core-revision-preflight.test.sh
|
|
99
|
+
|
|
100
|
+
set -u
|
|
101
|
+
|
|
102
|
+
CURL=${PREFLIGHT_CURL:-curl}
|
|
103
|
+
|
|
104
|
+
MIN=${CORE_MIN_ROUTE_REVISION:-}
|
|
105
|
+
if [ -z "$MIN" ]; then
|
|
106
|
+
# Loud on purpose (#1635 prosecution finding 2). A green "Preflight — core
|
|
107
|
+
# route revision" step looks identical whether it actually checked
|
|
108
|
+
# something or checked nothing — the whole point of #1604 is to stop a
|
|
109
|
+
# sibling deploying blind, so its own "nothing to check" state must never
|
|
110
|
+
# read like "checked and fine". Same treatment this repo already gives the
|
|
111
|
+
# structurally identical deliberately-off case
|
|
112
|
+
# (SIBLING_DEPLOY_ENABLED=false in _skeletons/sibling-template's own
|
|
113
|
+
# deploy.yml): a GitHub Actions ::notice:: annotation, not a bare echo that
|
|
114
|
+
# only shows up if someone opens the step log.
|
|
115
|
+
MSG="core-revision-preflight: preflight did NOT run — CORE_MIN_ROUTE_REVISION is not set, so this sibling has declared no core-route dependency. This is 'nothing was checked', not 'core was checked and is ready'; set CORE_MIN_ROUTE_REVISION once this sibling depends on a specific core revision."
|
|
116
|
+
echo "::notice::${MSG}"
|
|
117
|
+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
118
|
+
echo "${MSG}" >> "$GITHUB_STEP_SUMMARY"
|
|
119
|
+
fi
|
|
120
|
+
exit 0
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
case "$MIN" in
|
|
124
|
+
''|*[!0-9]*)
|
|
125
|
+
echo "::error::core-revision-preflight: CORE_MIN_ROUTE_REVISION='$MIN' is not a non-negative integer." >&2
|
|
126
|
+
exit 1
|
|
127
|
+
;;
|
|
128
|
+
esac
|
|
129
|
+
|
|
130
|
+
URL=${CORE_ROUTE_REVISION_URL:-}
|
|
131
|
+
if [ -z "$URL" ]; then
|
|
132
|
+
echo "::error::core-revision-preflight: CORE_MIN_ROUTE_REVISION=$MIN is declared but CORE_ROUTE_REVISION_URL is not set — cannot fetch the digest to check it against. Refusing to proceed rather than assuming core is ready." >&2
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
BODY_FILE=/tmp/core-revision-preflight-body.$$
|
|
137
|
+
ERR_FILE=/tmp/core-revision-preflight-err.$$
|
|
138
|
+
trap 'rm -f "$BODY_FILE" "$ERR_FILE"' EXIT
|
|
139
|
+
|
|
140
|
+
HTTP_CODE=$("$CURL" -sS --max-time 15 --retry 2 --retry-delay 2 \
|
|
141
|
+
-o "$BODY_FILE" -w '%{http_code}' "$URL" 2>"$ERR_FILE")
|
|
142
|
+
RC=$?
|
|
143
|
+
ERR=$(cat "$ERR_FILE" 2>/dev/null)
|
|
144
|
+
|
|
145
|
+
if [ "$RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
|
146
|
+
echo "::error::core-revision-preflight: no route-revision digest available at $URL (curl exit $RC, http ${HTTP_CODE:-none}${ERR:+, $ERR}). An absent or unreachable digest is treated as NOT satisfied — this is expected on an older core, a failed publish, or a CDN cache miss, and is a DIFFERENT fact from 'core is behind revision $MIN'. Refusing to deploy rather than assuming core is ready." >&2
|
|
147
|
+
exit 1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
ACTUAL=$(command -v jq >/dev/null 2>&1 && jq -r 'if (.revision | type) == "number" then (.revision | floor) else empty end' "$BODY_FILE" 2>/dev/null)
|
|
151
|
+
|
|
152
|
+
case "${ACTUAL:-}" in
|
|
153
|
+
''|*[!0-9]*)
|
|
154
|
+
echo "::error::core-revision-preflight: digest at $URL did not contain a valid integer 'revision' field. Treating a malformed digest the same as a missing one — refusing to deploy." >&2
|
|
155
|
+
exit 1
|
|
156
|
+
;;
|
|
157
|
+
esac
|
|
158
|
+
|
|
159
|
+
if [ "$ACTUAL" -ge "$MIN" ]; then
|
|
160
|
+
echo "core-revision-preflight: OK — core is at revision $ACTUAL, this sibling needs at least $MIN."
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
echo "::error::core-revision-preflight: core is at revision $ACTUAL, but this sibling needs at least $MIN. Core has not deployed the routes this sibling depends on yet — refusing to deploy ahead of it." >&2
|
|
165
|
+
exit 1
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves core-revision-preflight.sh (#1604) actually blocks a sibling that
|
|
4
|
+
# would go live ahead of the core routes it depends on, passes it once core
|
|
5
|
+
# has caught up, and fails CLOSED — never open — when the digest is missing.
|
|
6
|
+
#
|
|
7
|
+
# Stubs `curl` on PATH so this needs no network and no live deployment.
|
|
8
|
+
#
|
|
9
|
+
# Run: sh scripts/core-revision-preflight.test.sh
|
|
10
|
+
|
|
11
|
+
set -u
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
14
|
+
TARGET="$SCRIPT_DIR/core-revision-preflight.sh"
|
|
15
|
+
STUB_DIR=$(mktemp -d)
|
|
16
|
+
OUT_FILE=/tmp/core-revision-preflight-test-out.$$
|
|
17
|
+
trap 'rm -rf "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
18
|
+
|
|
19
|
+
FAILURES=0
|
|
20
|
+
URL="https://dev.example.com/.well-known/route-revision.json"
|
|
21
|
+
|
|
22
|
+
# _run <env assignments...> -- runs the target with PREFLIGHT_CURL pointed at
|
|
23
|
+
# the stub, capturing combined output to $OUT_FILE and the exit code into
|
|
24
|
+
# $LAST_RC. NOT run inside a subshell/command-substitution, so a caller that
|
|
25
|
+
# wants to assert on both the exit code AND FAILURES afterward gets both —
|
|
26
|
+
# an earlier version of this test ran the assertion inside `out=$(...)`,
|
|
27
|
+
# which put the whole check in a subshell: FAILURES++ there never reached
|
|
28
|
+
# the parent shell, so a real regression could not fail this test.
|
|
29
|
+
_run() {
|
|
30
|
+
env "$@" sh -c "PREFLIGHT_CURL='$STUB_DIR/curl' sh '$TARGET'" >"$OUT_FILE" 2>&1
|
|
31
|
+
LAST_RC=$?
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_assert_exit() {
|
|
35
|
+
# _assert_exit <scenario-name> <expected-exit>
|
|
36
|
+
name=$1
|
|
37
|
+
expected=$2
|
|
38
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
39
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
40
|
+
else
|
|
41
|
+
echo "FAIL: $name — expected exit $expected, got $LAST_RC"
|
|
42
|
+
echo "--- output ---"
|
|
43
|
+
cat "$OUT_FILE"
|
|
44
|
+
echo "--------------"
|
|
45
|
+
FAILURES=$((FAILURES + 1))
|
|
46
|
+
fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_assert_output_contains() {
|
|
50
|
+
# _assert_output_contains <scenario-name> <needle>
|
|
51
|
+
name=$1
|
|
52
|
+
needle=$2
|
|
53
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
54
|
+
echo "PASS: $name mentions '$needle'"
|
|
55
|
+
else
|
|
56
|
+
echo "FAIL: $name — expected output to mention '$needle'"
|
|
57
|
+
echo "--- output ---"
|
|
58
|
+
cat "$OUT_FILE"
|
|
59
|
+
echo "--------------"
|
|
60
|
+
FAILURES=$((FAILURES + 1))
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# --- Stub curl factory --------------------------------------------------
|
|
65
|
+
# Writes a stub that returns a fixed http status + body for the one URL this
|
|
66
|
+
# script ever fetches, or simulates a transport failure.
|
|
67
|
+
_write_stub_ok() {
|
|
68
|
+
revision=$1
|
|
69
|
+
cat > "$STUB_DIR/curl" <<STUB
|
|
70
|
+
#!/usr/bin/env sh
|
|
71
|
+
outfile=""
|
|
72
|
+
prev=""
|
|
73
|
+
for a in "\$@"; do
|
|
74
|
+
case "\$prev" in
|
|
75
|
+
-o) outfile="\$a" ;;
|
|
76
|
+
esac
|
|
77
|
+
prev="\$a"
|
|
78
|
+
done
|
|
79
|
+
printf '{"revision": $revision, "hash": "deadbeef", "routeCount": 70, "generatedAt": "2026-08-17T00:00:00Z"}' > "\$outfile"
|
|
80
|
+
printf '200'
|
|
81
|
+
exit 0
|
|
82
|
+
STUB
|
|
83
|
+
chmod +x "$STUB_DIR/curl"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_write_stub_unreachable() {
|
|
87
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
88
|
+
#!/usr/bin/env sh
|
|
89
|
+
echo "curl: (6) Could not resolve host: dev.example.com" >&2
|
|
90
|
+
exit 6
|
|
91
|
+
STUB
|
|
92
|
+
chmod +x "$STUB_DIR/curl"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_write_stub_404() {
|
|
96
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
97
|
+
#!/usr/bin/env sh
|
|
98
|
+
outfile=""
|
|
99
|
+
prev=""
|
|
100
|
+
for a in "$@"; do
|
|
101
|
+
case "$prev" in
|
|
102
|
+
-o) outfile="$a" ;;
|
|
103
|
+
esac
|
|
104
|
+
prev="$a"
|
|
105
|
+
done
|
|
106
|
+
printf 'Not Found' > "$outfile"
|
|
107
|
+
printf '404'
|
|
108
|
+
exit 0
|
|
109
|
+
STUB
|
|
110
|
+
chmod +x "$STUB_DIR/curl"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_write_stub_never_called() {
|
|
114
|
+
# A curl invocation in this scenario is itself the bug — the script must
|
|
115
|
+
# short-circuit before ever fetching.
|
|
116
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
117
|
+
#!/usr/bin/env sh
|
|
118
|
+
echo "curl invoked when it should not have been" >&2
|
|
119
|
+
exit 99
|
|
120
|
+
STUB
|
|
121
|
+
chmod +x "$STUB_DIR/curl"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# 1. No CORE_MIN_ROUTE_REVISION declared at all — this sibling depends on
|
|
125
|
+
# nothing new from core. Must pass WITHOUT even invoking curl, and must
|
|
126
|
+
# say so LOUDLY: a GitHub Actions ::notice:: annotation (not a bare echo
|
|
127
|
+
# a reader would have to open the step log to find), and never wording
|
|
128
|
+
# that could be mistaken for "checked and fine" (#1635 prosecution
|
|
129
|
+
# finding 2 — the same treatment this repo already gives
|
|
130
|
+
# SIBLING_DEPLOY_ENABLED=false).
|
|
131
|
+
_write_stub_never_called
|
|
132
|
+
_run -u CORE_MIN_ROUTE_REVISION -u CORE_ROUTE_REVISION_URL --
|
|
133
|
+
_assert_exit "no minimum declared" 0
|
|
134
|
+
_assert_output_contains "no minimum declared — is a ::notice:: annotation" "::notice::"
|
|
135
|
+
_assert_output_contains "no minimum declared — says preflight did not run" "did NOT run"
|
|
136
|
+
|
|
137
|
+
# 1b. Same scenario, but with GITHUB_STEP_SUMMARY set as it is in a real
|
|
138
|
+
# GitHub Actions run — the skip must ALSO land in the job summary, not
|
|
139
|
+
# only the step log, matching how this repo's own deploy-app.yml surfaces
|
|
140
|
+
# a deploy-time skip/failure to a reader who never opens raw logs.
|
|
141
|
+
SUMMARY_FILE=/tmp/core-revision-preflight-test-summary.$$
|
|
142
|
+
: > "$SUMMARY_FILE"
|
|
143
|
+
_write_stub_never_called
|
|
144
|
+
_run -u CORE_MIN_ROUTE_REVISION -u CORE_ROUTE_REVISION_URL -- GITHUB_STEP_SUMMARY="$SUMMARY_FILE"
|
|
145
|
+
_assert_exit "no minimum declared, with GITHUB_STEP_SUMMARY set" 0
|
|
146
|
+
if grep -qF "did NOT run" "$SUMMARY_FILE" 2>/dev/null; then
|
|
147
|
+
echo "PASS: no minimum declared — writes to \$GITHUB_STEP_SUMMARY"
|
|
148
|
+
else
|
|
149
|
+
echo "FAIL: no minimum declared — expected \$GITHUB_STEP_SUMMARY ($SUMMARY_FILE) to mention 'did NOT run'"
|
|
150
|
+
echo "--- summary file ---"
|
|
151
|
+
cat "$SUMMARY_FILE" 2>/dev/null
|
|
152
|
+
echo "--------------------"
|
|
153
|
+
FAILURES=$((FAILURES + 1))
|
|
154
|
+
fi
|
|
155
|
+
rm -f "$SUMMARY_FILE"
|
|
156
|
+
|
|
157
|
+
# 2. Sibling requires a revision core HAS reached (core is AHEAD) — passes.
|
|
158
|
+
_write_stub_ok 50
|
|
159
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
160
|
+
_assert_exit "core ahead of required revision (42 <= 50)" 0
|
|
161
|
+
_assert_output_contains "core ahead — names actual" "revision 50"
|
|
162
|
+
|
|
163
|
+
# 2b. Exact match — core is AT the required revision — passes.
|
|
164
|
+
_write_stub_ok 42
|
|
165
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
166
|
+
_assert_exit "core exactly at required revision" 0
|
|
167
|
+
|
|
168
|
+
# 3. Sibling requires a revision core has NOT reached — fails, naming both
|
|
169
|
+
# the required and actual revisions.
|
|
170
|
+
_write_stub_ok 10
|
|
171
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
172
|
+
_assert_exit "core behind required revision" 1
|
|
173
|
+
_assert_output_contains "core behind — names actual" "revision 10"
|
|
174
|
+
_assert_output_contains "core behind — names required" "at least 42"
|
|
175
|
+
|
|
176
|
+
# 4. No digest published at all (transport failure) — fails CLOSED, and says
|
|
177
|
+
# the digest was missing rather than that the check passed.
|
|
178
|
+
_write_stub_unreachable
|
|
179
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
180
|
+
_assert_exit "digest unreachable" 1
|
|
181
|
+
_assert_output_contains "digest unreachable — says missing, not behind" "no route-revision digest available"
|
|
182
|
+
|
|
183
|
+
# 5. Digest endpoint 404s (older core / never published) — fails CLOSED, same
|
|
184
|
+
# "missing" message as scenario 4, never "core is behind".
|
|
185
|
+
_write_stub_404
|
|
186
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
187
|
+
_assert_exit "digest 404" 1
|
|
188
|
+
_assert_output_contains "digest 404 — says missing" "no route-revision digest available"
|
|
189
|
+
|
|
190
|
+
# 6. A minimum is declared but CORE_ROUTE_REVISION_URL is not set — must fail
|
|
191
|
+
# without invoking curl (misconfiguration, not "core is ready").
|
|
192
|
+
_write_stub_never_called
|
|
193
|
+
_run -u CORE_ROUTE_REVISION_URL -- CORE_MIN_ROUTE_REVISION=42
|
|
194
|
+
_assert_exit "minimum declared but URL unset" 1
|
|
195
|
+
|
|
196
|
+
echo
|
|
197
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
198
|
+
echo "core-revision-preflight.test.sh: all checks passed."
|
|
199
|
+
exit 0
|
|
200
|
+
else
|
|
201
|
+
echo "core-revision-preflight.test.sh: $FAILURES check(s) failed."
|
|
202
|
+
exit 1
|
|
203
|
+
fi
|
|
@@ -248,6 +248,17 @@ jobs:
|
|
|
248
248
|
# failures against a stubbed curl. A verification script with no
|
|
249
249
|
# caller is the class tracked in #1413; this is that caller.
|
|
250
250
|
run: sh ../../scripts/routing-smoke-test.test.sh
|
|
251
|
+
- name: Core-revision preflight — self-test
|
|
252
|
+
if: ${{ !cancelled() }}
|
|
253
|
+
# Same shape as the routing smoke test above: not exercising a real
|
|
254
|
+
# core deployment (CI has none to reach), proves
|
|
255
|
+
# scripts/core-revision-preflight.sh — the deploy.yml
|
|
256
|
+
# preflight-core-revision job (#1604) that blocks this sibling from
|
|
257
|
+
# going live ahead of the core routes it depends on — still blocks a
|
|
258
|
+
# stale core, passes a current one, and fails CLOSED when the digest
|
|
259
|
+
# is missing, against a stubbed curl. Without this caller the script
|
|
260
|
+
# is the #1413 zero-caller class the instant it lands.
|
|
261
|
+
run: sh ../../scripts/core-revision-preflight.test.sh
|
|
251
262
|
|
|
252
263
|
security-secrets:
|
|
253
264
|
name: Secret Scan
|
|
@@ -141,9 +141,58 @@ jobs:
|
|
|
141
141
|
--body "$(terraform output -raw lambda_function_name)" \
|
|
142
142
|
--env "${{ needs.resolve-environment.outputs.environment }}"
|
|
143
143
|
|
|
144
|
+
# A sibling's deploy is structurally incapable of seeing core's deploy
|
|
145
|
+
# state: this workflow runs resolve-environment -> deploy-infra ->
|
|
146
|
+
# deploy-app with no step that reads anything about core, and its only
|
|
147
|
+
# post-deploy proof is its OWN Lambda health and its OWN CDN route. Merge
|
|
148
|
+
# ordering does not fix it either — in the recorded incident the core PR
|
|
149
|
+
# had already merged and the core deploy simply sat in the runner queue
|
|
150
|
+
# while the sibling went live first, serving requests against routes core
|
|
151
|
+
# had not shipped yet (#1604).
|
|
152
|
+
#
|
|
153
|
+
# Core publishes a DIGEST of its route table — never the surface itself,
|
|
154
|
+
# /openapi.json stays 401-gated — to the existing public .well-known/
|
|
155
|
+
# channel (scripts/route-table-digest.py in the core repo). This job asks
|
|
156
|
+
# that digest "is core at or past the revision this sibling needs" BEFORE
|
|
157
|
+
# deploy-app runs, and fails the whole workflow if it cannot say yes.
|
|
158
|
+
#
|
|
159
|
+
# Runs in parallel with deploy-infra (both depend only on
|
|
160
|
+
# resolve-environment) so it costs no extra wall-clock on a deploy that
|
|
161
|
+
# passes; deploy-app then needs both, so it never starts until this job
|
|
162
|
+
# has actually cleared.
|
|
163
|
+
preflight-core-revision:
|
|
164
|
+
name: Preflight — core route revision
|
|
165
|
+
needs: resolve-environment
|
|
166
|
+
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
167
|
+
environment: ${{ needs.resolve-environment.outputs.environment }}
|
|
168
|
+
if: vars.SIBLING_DEPLOY_ENABLED == 'true'
|
|
169
|
+
steps:
|
|
170
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
171
|
+
- name: Core-revision preflight
|
|
172
|
+
# scripts/core-revision-preflight.sh does the actual check; see its
|
|
173
|
+
# own header for the full reasoning (why a monotonic revision rather
|
|
174
|
+
# than the hash, why absence fails closed rather than being treated
|
|
175
|
+
# as satisfied — the #1363 fail-open class, on the deploy path,
|
|
176
|
+
# would fire during exactly the incident this exists to prevent).
|
|
177
|
+
#
|
|
178
|
+
# CORE_MIN_ROUTE_REVISION is how THIS sibling declares which core
|
|
179
|
+
# revision its code depends on — set by hand as a per-environment
|
|
180
|
+
# repo variable, the same way a version pin is set: after merging
|
|
181
|
+
# the core PR your code needs, read the `revision` core published
|
|
182
|
+
# for that deploy from /.well-known/route-revision.json and record
|
|
183
|
+
# it here. Unset means "this sibling depends on nothing new from
|
|
184
|
+
# core" and the check is skipped — that is the correct default for
|
|
185
|
+
# a sibling that has never declared a dependency, not a fail-open:
|
|
186
|
+
# nothing here is being checked FOR IT to pass, there is simply
|
|
187
|
+
# nothing to check yet.
|
|
188
|
+
env:
|
|
189
|
+
CORE_MIN_ROUTE_REVISION: ${{ vars.CORE_MIN_ROUTE_REVISION }}
|
|
190
|
+
CORE_ROUTE_REVISION_URL: ${{ vars.CORE_PORTAL_URL }}/.well-known/route-revision.json
|
|
191
|
+
run: sh scripts/core-revision-preflight.sh
|
|
192
|
+
|
|
144
193
|
deploy-app:
|
|
145
194
|
name: Deploy app
|
|
146
|
-
needs: [resolve-environment, deploy-infra]
|
|
195
|
+
needs: [resolve-environment, deploy-infra, preflight-core-revision]
|
|
147
196
|
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
148
197
|
environment: ${{ needs.resolve-environment.outputs.environment }}
|
|
149
198
|
steps:
|
|
@@ -345,7 +394,7 @@ jobs:
|
|
|
345
394
|
# and "never configured" are different facts and only one of them is a defect:
|
|
346
395
|
deploy-status:
|
|
347
396
|
name: Deploy status
|
|
348
|
-
needs: [resolve-environment, deploy-infra, deploy-app]
|
|
397
|
+
needs: [resolve-environment, deploy-infra, preflight-core-revision, deploy-app]
|
|
349
398
|
if: always()
|
|
350
399
|
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
351
400
|
steps:
|
|
@@ -353,9 +402,10 @@ jobs:
|
|
|
353
402
|
env:
|
|
354
403
|
ENABLED: ${{ vars.SIBLING_DEPLOY_ENABLED }}
|
|
355
404
|
INFRA: ${{ needs.deploy-infra.result }}
|
|
405
|
+
PREFLIGHT: ${{ needs.preflight-core-revision.result }}
|
|
356
406
|
APP: ${{ needs.deploy-app.result }}
|
|
357
407
|
run: |
|
|
358
|
-
echo "SIBLING_DEPLOY_ENABLED='${ENABLED}' deploy-infra=${INFRA} deploy-app=${APP}"
|
|
408
|
+
echo "SIBLING_DEPLOY_ENABLED='${ENABLED}' deploy-infra=${INFRA} preflight-core-revision=${PREFLIGHT} deploy-app=${APP}"
|
|
359
409
|
|
|
360
410
|
if [ "${ENABLED}" = "false" ]; then
|
|
361
411
|
echo "::notice::Deploys are deliberately disabled for this repo (SIBLING_DEPLOY_ENABLED=false). Nothing was deployed, by choice."
|
|
@@ -368,10 +418,14 @@ jobs:
|
|
|
368
418
|
fi
|
|
369
419
|
|
|
370
420
|
# A real failure already makes the run red for the right reason, and
|
|
371
|
-
# `deploy-app` needs `deploy-infra`
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
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
|
|
375
429
|
*failure*|*cancelled*)
|
|
376
430
|
echo "A deploy job failed or was cancelled; that failure is the run's result. Not adding a second error."
|
|
377
431
|
exit 0
|
|
@@ -380,7 +434,7 @@ jobs:
|
|
|
380
434
|
|
|
381
435
|
# Enabled — the jobs were meant to run. 'skipped' here means a gate
|
|
382
436
|
# upstream of them suppressed the work while the run still went green.
|
|
383
|
-
for pair in "deploy-infra:${INFRA}" "deploy-app:${APP}"; do
|
|
437
|
+
for pair in "deploy-infra:${INFRA}" "preflight-core-revision:${PREFLIGHT}" "deploy-app:${APP}"; do
|
|
384
438
|
job="${pair%%:*}"
|
|
385
439
|
result="${pair#*:}"
|
|
386
440
|
if [ "${result}" = "skipped" ]; then
|
|
@@ -389,4 +443,4 @@ jobs:
|
|
|
389
443
|
fi
|
|
390
444
|
done
|
|
391
445
|
|
|
392
|
-
echo "Deploy jobs ran (infra=${INFRA}, app=${APP})."
|
|
446
|
+
echo "Deploy jobs ran (infra=${INFRA}, preflight=${PREFLIGHT}, app=${APP})."
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Core-revision preflight (#1604).
|
|
4
|
+
#
|
|
5
|
+
# ## Why this exists
|
|
6
|
+
#
|
|
7
|
+
# A sibling's deploy is structurally incapable of seeing core's deploy state:
|
|
8
|
+
# `resolve-environment -> deploy-infra -> deploy-app` runs with no step that
|
|
9
|
+
# reads anything about core, and its only post-deploy proof is its OWN Lambda
|
|
10
|
+
# health and its OWN CDN route. Merge ordering does not fix this either — in
|
|
11
|
+
# the recorded incident the core PR had already merged and the core deploy
|
|
12
|
+
# simply sat in the runner queue while the sibling went live first, serving
|
|
13
|
+
# requests against routes core had not shipped yet.
|
|
14
|
+
#
|
|
15
|
+
# The owner's decision (#1604): core publishes a DIGEST of its route table to
|
|
16
|
+
# the existing public `.well-known/` channel — a monotonic revision counter
|
|
17
|
+
# paired with a content hash, never the route surface itself (`/openapi.json`
|
|
18
|
+
# stays 401-gated). This script is the consumer half: it asks "is core at
|
|
19
|
+
# least at the revision I need", using ONLY that published digest, run
|
|
20
|
+
# BEFORE `deploy-app` so a sibling that would go live ahead of core never
|
|
21
|
+
# gets that far.
|
|
22
|
+
#
|
|
23
|
+
# ## Why "revision", not the hash
|
|
24
|
+
#
|
|
25
|
+
# The hash proves the route table is DIFFERENT from some prior observation;
|
|
26
|
+
# it cannot prove "at or past", because "different" has no direction. The
|
|
27
|
+
# published `revision` is core's `git rev-list --count HEAD` at deploy time
|
|
28
|
+
# (scripts/route-table-digest.py) — the number of commits reachable from the
|
|
29
|
+
# deployed commit, not a counter that increments once per deploy. That
|
|
30
|
+
# distinction is the whole point: a deploy counter goes up on every run
|
|
31
|
+
# regardless of what shipped, so a ROLLBACK (a new deploy of an OLDER
|
|
32
|
+
# commit) would report a revision higher than the one that actually had the
|
|
33
|
+
# routes a sibling needs — a false "yes, at or past" fired exactly when the
|
|
34
|
+
# true answer is "no". A commit count is tied to the code's real position in
|
|
35
|
+
# history: redeploying the same commit reports the same revision (correctly
|
|
36
|
+
# "at least as new" — an equality, not a regression); rolling back to an
|
|
37
|
+
# older commit reports a LOWER revision, because that commit genuinely has
|
|
38
|
+
# fewer ancestors. This script only ever compares that one integer.
|
|
39
|
+
#
|
|
40
|
+
# ## This script's monotonicity guarantee is borrowed, not owned
|
|
41
|
+
#
|
|
42
|
+
# `git rev-list --count HEAD` is only monotonic/collision-free along one
|
|
43
|
+
# continuous, non-rewritten history. If core's `dev`/`staging`/`main` ever
|
|
44
|
+
# allowed force pushes or a non-linear history, a rewritten branch could
|
|
45
|
+
# report a LOWER commit count for a commit that is, in reality, later —
|
|
46
|
+
# silently undermining the one property this whole mechanism depends on,
|
|
47
|
+
# with no signal anywhere in this script or its caller. Checked directly
|
|
48
|
+
# against the live `keiranholloway/biffo-platform` repo during #1635's
|
|
49
|
+
# prosecution: `allow_force_pushes: false`, `required_linear_history: true`
|
|
50
|
+
# and `enforce_admins: true` are all set on every one of those branches
|
|
51
|
+
# today, so the guarantee currently holds — but nothing in this script (or
|
|
52
|
+
# in scripts/route-table-digest.py, which computes the number on the
|
|
53
|
+
# publishing side) asserts that, or would notice if it changed. Deliberately
|
|
54
|
+
# NOT asserted with a live GitHub API call here: this script runs in every
|
|
55
|
+
# sibling's deploy job, on every deploy, and a branch-protection check would
|
|
56
|
+
# add a second network dependency and a second way to fail closed for a
|
|
57
|
+
# property that is core's to guarantee, not each sibling's to re-verify on
|
|
58
|
+
# every run. If this ever needs enforcing rather than documenting, it
|
|
59
|
+
# belongs as a periodic check against the CORE repo (once, not once per
|
|
60
|
+
# sibling deploy) — not bolted onto this script.
|
|
61
|
+
#
|
|
62
|
+
# ## Fail-closed (the #1363 class)
|
|
63
|
+
#
|
|
64
|
+
# A sibling that declares no minimum has nothing to check — that is a
|
|
65
|
+
# legitimate "I depend on nothing new from core" state, and it passes with a
|
|
66
|
+
# notice. But once a minimum IS declared, absence of the digest — an older
|
|
67
|
+
# core, a failed publish, a CDN cache miss — must never read as "satisfied".
|
|
68
|
+
# It fails, and says WHICH: "missing/unreachable" and "present but behind"
|
|
69
|
+
# are different facts, and a caller reading only the exit code must not be
|
|
70
|
+
# able to mistake one for the other from the log.
|
|
71
|
+
#
|
|
72
|
+
# Exit codes:
|
|
73
|
+
# 0 = no minimum declared (nothing to check), or core is at/past it.
|
|
74
|
+
# 1 = core is behind the declared minimum, the digest is missing or
|
|
75
|
+
# unreachable, or the digest could not be parsed. Every one of these
|
|
76
|
+
# is "not verified as safe" and none may be treated as a pass.
|
|
77
|
+
#
|
|
78
|
+
# ## Usage
|
|
79
|
+
#
|
|
80
|
+
# CORE_MIN_ROUTE_REVISION=42 \
|
|
81
|
+
# CORE_ROUTE_REVISION_URL=https://dev.example.com/.well-known/route-revision.json \
|
|
82
|
+
# sh scripts/core-revision-preflight.sh
|
|
83
|
+
#
|
|
84
|
+
# CORE_MIN_ROUTE_REVISION — the minimum core revision this sibling's code
|
|
85
|
+
# needs. Set as a per-environment repo variable
|
|
86
|
+
# (vars.CORE_MIN_ROUTE_REVISION), the same way a
|
|
87
|
+
# version pin is set by hand: after merging the
|
|
88
|
+
# core PR your sibling code depends on, read the
|
|
89
|
+
# `revision` core published for that deploy and
|
|
90
|
+
# record it here. Unset/empty means "no new
|
|
91
|
+
# dependency" and the check is skipped.
|
|
92
|
+
# CORE_ROUTE_REVISION_URL — where to fetch the digest, typically
|
|
93
|
+
# "${CORE_PORTAL_URL}/.well-known/route-revision.json".
|
|
94
|
+
# Required whenever a minimum is declared.
|
|
95
|
+
# PREFLIGHT_CURL — override the curl binary/wrapper (tests use
|
|
96
|
+
# this to point at a stub).
|
|
97
|
+
#
|
|
98
|
+
# Run this file's own tests: sh scripts/core-revision-preflight.test.sh
|
|
99
|
+
|
|
100
|
+
set -u
|
|
101
|
+
|
|
102
|
+
CURL=${PREFLIGHT_CURL:-curl}
|
|
103
|
+
|
|
104
|
+
MIN=${CORE_MIN_ROUTE_REVISION:-}
|
|
105
|
+
if [ -z "$MIN" ]; then
|
|
106
|
+
# Loud on purpose (#1635 prosecution finding 2). A green "Preflight — core
|
|
107
|
+
# route revision" step looks identical whether it actually checked
|
|
108
|
+
# something or checked nothing — the whole point of #1604 is to stop a
|
|
109
|
+
# sibling deploying blind, so its own "nothing to check" state must never
|
|
110
|
+
# read like "checked and fine". Same treatment this repo already gives the
|
|
111
|
+
# structurally identical deliberately-off case
|
|
112
|
+
# (SIBLING_DEPLOY_ENABLED=false in _skeletons/sibling-template's own
|
|
113
|
+
# deploy.yml): a GitHub Actions ::notice:: annotation, not a bare echo that
|
|
114
|
+
# only shows up if someone opens the step log.
|
|
115
|
+
MSG="core-revision-preflight: preflight did NOT run — CORE_MIN_ROUTE_REVISION is not set, so this sibling has declared no core-route dependency. This is 'nothing was checked', not 'core was checked and is ready'; set CORE_MIN_ROUTE_REVISION once this sibling depends on a specific core revision."
|
|
116
|
+
echo "::notice::${MSG}"
|
|
117
|
+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
118
|
+
echo "${MSG}" >> "$GITHUB_STEP_SUMMARY"
|
|
119
|
+
fi
|
|
120
|
+
exit 0
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
case "$MIN" in
|
|
124
|
+
''|*[!0-9]*)
|
|
125
|
+
echo "::error::core-revision-preflight: CORE_MIN_ROUTE_REVISION='$MIN' is not a non-negative integer." >&2
|
|
126
|
+
exit 1
|
|
127
|
+
;;
|
|
128
|
+
esac
|
|
129
|
+
|
|
130
|
+
URL=${CORE_ROUTE_REVISION_URL:-}
|
|
131
|
+
if [ -z "$URL" ]; then
|
|
132
|
+
echo "::error::core-revision-preflight: CORE_MIN_ROUTE_REVISION=$MIN is declared but CORE_ROUTE_REVISION_URL is not set — cannot fetch the digest to check it against. Refusing to proceed rather than assuming core is ready." >&2
|
|
133
|
+
exit 1
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
BODY_FILE=/tmp/core-revision-preflight-body.$$
|
|
137
|
+
ERR_FILE=/tmp/core-revision-preflight-err.$$
|
|
138
|
+
trap 'rm -f "$BODY_FILE" "$ERR_FILE"' EXIT
|
|
139
|
+
|
|
140
|
+
HTTP_CODE=$("$CURL" -sS --max-time 15 --retry 2 --retry-delay 2 \
|
|
141
|
+
-o "$BODY_FILE" -w '%{http_code}' "$URL" 2>"$ERR_FILE")
|
|
142
|
+
RC=$?
|
|
143
|
+
ERR=$(cat "$ERR_FILE" 2>/dev/null)
|
|
144
|
+
|
|
145
|
+
if [ "$RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
|
146
|
+
echo "::error::core-revision-preflight: no route-revision digest available at $URL (curl exit $RC, http ${HTTP_CODE:-none}${ERR:+, $ERR}). An absent or unreachable digest is treated as NOT satisfied — this is expected on an older core, a failed publish, or a CDN cache miss, and is a DIFFERENT fact from 'core is behind revision $MIN'. Refusing to deploy rather than assuming core is ready." >&2
|
|
147
|
+
exit 1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
ACTUAL=$(command -v jq >/dev/null 2>&1 && jq -r 'if (.revision | type) == "number" then (.revision | floor) else empty end' "$BODY_FILE" 2>/dev/null)
|
|
151
|
+
|
|
152
|
+
case "${ACTUAL:-}" in
|
|
153
|
+
''|*[!0-9]*)
|
|
154
|
+
echo "::error::core-revision-preflight: digest at $URL did not contain a valid integer 'revision' field. Treating a malformed digest the same as a missing one — refusing to deploy." >&2
|
|
155
|
+
exit 1
|
|
156
|
+
;;
|
|
157
|
+
esac
|
|
158
|
+
|
|
159
|
+
if [ "$ACTUAL" -ge "$MIN" ]; then
|
|
160
|
+
echo "core-revision-preflight: OK — core is at revision $ACTUAL, this sibling needs at least $MIN."
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
echo "::error::core-revision-preflight: core is at revision $ACTUAL, but this sibling needs at least $MIN. Core has not deployed the routes this sibling depends on yet — refusing to deploy ahead of it." >&2
|
|
165
|
+
exit 1
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
#
|
|
3
|
+
# Proves core-revision-preflight.sh (#1604) actually blocks a sibling that
|
|
4
|
+
# would go live ahead of the core routes it depends on, passes it once core
|
|
5
|
+
# has caught up, and fails CLOSED — never open — when the digest is missing.
|
|
6
|
+
#
|
|
7
|
+
# Stubs `curl` on PATH so this needs no network and no live deployment.
|
|
8
|
+
#
|
|
9
|
+
# Run: sh scripts/core-revision-preflight.test.sh
|
|
10
|
+
|
|
11
|
+
set -u
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
14
|
+
TARGET="$SCRIPT_DIR/core-revision-preflight.sh"
|
|
15
|
+
STUB_DIR=$(mktemp -d)
|
|
16
|
+
OUT_FILE=/tmp/core-revision-preflight-test-out.$$
|
|
17
|
+
trap 'rm -rf "$STUB_DIR"; rm -f "$OUT_FILE"' EXIT
|
|
18
|
+
|
|
19
|
+
FAILURES=0
|
|
20
|
+
URL="https://dev.example.com/.well-known/route-revision.json"
|
|
21
|
+
|
|
22
|
+
# _run <env assignments...> -- runs the target with PREFLIGHT_CURL pointed at
|
|
23
|
+
# the stub, capturing combined output to $OUT_FILE and the exit code into
|
|
24
|
+
# $LAST_RC. NOT run inside a subshell/command-substitution, so a caller that
|
|
25
|
+
# wants to assert on both the exit code AND FAILURES afterward gets both —
|
|
26
|
+
# an earlier version of this test ran the assertion inside `out=$(...)`,
|
|
27
|
+
# which put the whole check in a subshell: FAILURES++ there never reached
|
|
28
|
+
# the parent shell, so a real regression could not fail this test.
|
|
29
|
+
_run() {
|
|
30
|
+
env "$@" sh -c "PREFLIGHT_CURL='$STUB_DIR/curl' sh '$TARGET'" >"$OUT_FILE" 2>&1
|
|
31
|
+
LAST_RC=$?
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_assert_exit() {
|
|
35
|
+
# _assert_exit <scenario-name> <expected-exit>
|
|
36
|
+
name=$1
|
|
37
|
+
expected=$2
|
|
38
|
+
if [ "$LAST_RC" -eq "$expected" ]; then
|
|
39
|
+
echo "PASS: $name (exit $LAST_RC)"
|
|
40
|
+
else
|
|
41
|
+
echo "FAIL: $name — expected exit $expected, got $LAST_RC"
|
|
42
|
+
echo "--- output ---"
|
|
43
|
+
cat "$OUT_FILE"
|
|
44
|
+
echo "--------------"
|
|
45
|
+
FAILURES=$((FAILURES + 1))
|
|
46
|
+
fi
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_assert_output_contains() {
|
|
50
|
+
# _assert_output_contains <scenario-name> <needle>
|
|
51
|
+
name=$1
|
|
52
|
+
needle=$2
|
|
53
|
+
if grep -qF "$needle" "$OUT_FILE"; then
|
|
54
|
+
echo "PASS: $name mentions '$needle'"
|
|
55
|
+
else
|
|
56
|
+
echo "FAIL: $name — expected output to mention '$needle'"
|
|
57
|
+
echo "--- output ---"
|
|
58
|
+
cat "$OUT_FILE"
|
|
59
|
+
echo "--------------"
|
|
60
|
+
FAILURES=$((FAILURES + 1))
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# --- Stub curl factory --------------------------------------------------
|
|
65
|
+
# Writes a stub that returns a fixed http status + body for the one URL this
|
|
66
|
+
# script ever fetches, or simulates a transport failure.
|
|
67
|
+
_write_stub_ok() {
|
|
68
|
+
revision=$1
|
|
69
|
+
cat > "$STUB_DIR/curl" <<STUB
|
|
70
|
+
#!/usr/bin/env sh
|
|
71
|
+
outfile=""
|
|
72
|
+
prev=""
|
|
73
|
+
for a in "\$@"; do
|
|
74
|
+
case "\$prev" in
|
|
75
|
+
-o) outfile="\$a" ;;
|
|
76
|
+
esac
|
|
77
|
+
prev="\$a"
|
|
78
|
+
done
|
|
79
|
+
printf '{"revision": $revision, "hash": "deadbeef", "routeCount": 70, "generatedAt": "2026-08-17T00:00:00Z"}' > "\$outfile"
|
|
80
|
+
printf '200'
|
|
81
|
+
exit 0
|
|
82
|
+
STUB
|
|
83
|
+
chmod +x "$STUB_DIR/curl"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_write_stub_unreachable() {
|
|
87
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
88
|
+
#!/usr/bin/env sh
|
|
89
|
+
echo "curl: (6) Could not resolve host: dev.example.com" >&2
|
|
90
|
+
exit 6
|
|
91
|
+
STUB
|
|
92
|
+
chmod +x "$STUB_DIR/curl"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_write_stub_404() {
|
|
96
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
97
|
+
#!/usr/bin/env sh
|
|
98
|
+
outfile=""
|
|
99
|
+
prev=""
|
|
100
|
+
for a in "$@"; do
|
|
101
|
+
case "$prev" in
|
|
102
|
+
-o) outfile="$a" ;;
|
|
103
|
+
esac
|
|
104
|
+
prev="$a"
|
|
105
|
+
done
|
|
106
|
+
printf 'Not Found' > "$outfile"
|
|
107
|
+
printf '404'
|
|
108
|
+
exit 0
|
|
109
|
+
STUB
|
|
110
|
+
chmod +x "$STUB_DIR/curl"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_write_stub_never_called() {
|
|
114
|
+
# A curl invocation in this scenario is itself the bug — the script must
|
|
115
|
+
# short-circuit before ever fetching.
|
|
116
|
+
cat > "$STUB_DIR/curl" <<'STUB'
|
|
117
|
+
#!/usr/bin/env sh
|
|
118
|
+
echo "curl invoked when it should not have been" >&2
|
|
119
|
+
exit 99
|
|
120
|
+
STUB
|
|
121
|
+
chmod +x "$STUB_DIR/curl"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# 1. No CORE_MIN_ROUTE_REVISION declared at all — this sibling depends on
|
|
125
|
+
# nothing new from core. Must pass WITHOUT even invoking curl, and must
|
|
126
|
+
# say so LOUDLY: a GitHub Actions ::notice:: annotation (not a bare echo
|
|
127
|
+
# a reader would have to open the step log to find), and never wording
|
|
128
|
+
# that could be mistaken for "checked and fine" (#1635 prosecution
|
|
129
|
+
# finding 2 — the same treatment this repo already gives
|
|
130
|
+
# SIBLING_DEPLOY_ENABLED=false).
|
|
131
|
+
_write_stub_never_called
|
|
132
|
+
_run -u CORE_MIN_ROUTE_REVISION -u CORE_ROUTE_REVISION_URL --
|
|
133
|
+
_assert_exit "no minimum declared" 0
|
|
134
|
+
_assert_output_contains "no minimum declared — is a ::notice:: annotation" "::notice::"
|
|
135
|
+
_assert_output_contains "no minimum declared — says preflight did not run" "did NOT run"
|
|
136
|
+
|
|
137
|
+
# 1b. Same scenario, but with GITHUB_STEP_SUMMARY set as it is in a real
|
|
138
|
+
# GitHub Actions run — the skip must ALSO land in the job summary, not
|
|
139
|
+
# only the step log, matching how this repo's own deploy-app.yml surfaces
|
|
140
|
+
# a deploy-time skip/failure to a reader who never opens raw logs.
|
|
141
|
+
SUMMARY_FILE=/tmp/core-revision-preflight-test-summary.$$
|
|
142
|
+
: > "$SUMMARY_FILE"
|
|
143
|
+
_write_stub_never_called
|
|
144
|
+
_run -u CORE_MIN_ROUTE_REVISION -u CORE_ROUTE_REVISION_URL -- GITHUB_STEP_SUMMARY="$SUMMARY_FILE"
|
|
145
|
+
_assert_exit "no minimum declared, with GITHUB_STEP_SUMMARY set" 0
|
|
146
|
+
if grep -qF "did NOT run" "$SUMMARY_FILE" 2>/dev/null; then
|
|
147
|
+
echo "PASS: no minimum declared — writes to \$GITHUB_STEP_SUMMARY"
|
|
148
|
+
else
|
|
149
|
+
echo "FAIL: no minimum declared — expected \$GITHUB_STEP_SUMMARY ($SUMMARY_FILE) to mention 'did NOT run'"
|
|
150
|
+
echo "--- summary file ---"
|
|
151
|
+
cat "$SUMMARY_FILE" 2>/dev/null
|
|
152
|
+
echo "--------------------"
|
|
153
|
+
FAILURES=$((FAILURES + 1))
|
|
154
|
+
fi
|
|
155
|
+
rm -f "$SUMMARY_FILE"
|
|
156
|
+
|
|
157
|
+
# 2. Sibling requires a revision core HAS reached (core is AHEAD) — passes.
|
|
158
|
+
_write_stub_ok 50
|
|
159
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
160
|
+
_assert_exit "core ahead of required revision (42 <= 50)" 0
|
|
161
|
+
_assert_output_contains "core ahead — names actual" "revision 50"
|
|
162
|
+
|
|
163
|
+
# 2b. Exact match — core is AT the required revision — passes.
|
|
164
|
+
_write_stub_ok 42
|
|
165
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
166
|
+
_assert_exit "core exactly at required revision" 0
|
|
167
|
+
|
|
168
|
+
# 3. Sibling requires a revision core has NOT reached — fails, naming both
|
|
169
|
+
# the required and actual revisions.
|
|
170
|
+
_write_stub_ok 10
|
|
171
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
172
|
+
_assert_exit "core behind required revision" 1
|
|
173
|
+
_assert_output_contains "core behind — names actual" "revision 10"
|
|
174
|
+
_assert_output_contains "core behind — names required" "at least 42"
|
|
175
|
+
|
|
176
|
+
# 4. No digest published at all (transport failure) — fails CLOSED, and says
|
|
177
|
+
# the digest was missing rather than that the check passed.
|
|
178
|
+
_write_stub_unreachable
|
|
179
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
180
|
+
_assert_exit "digest unreachable" 1
|
|
181
|
+
_assert_output_contains "digest unreachable — says missing, not behind" "no route-revision digest available"
|
|
182
|
+
|
|
183
|
+
# 5. Digest endpoint 404s (older core / never published) — fails CLOSED, same
|
|
184
|
+
# "missing" message as scenario 4, never "core is behind".
|
|
185
|
+
_write_stub_404
|
|
186
|
+
_run CORE_MIN_ROUTE_REVISION=42 CORE_ROUTE_REVISION_URL="$URL"
|
|
187
|
+
_assert_exit "digest 404" 1
|
|
188
|
+
_assert_output_contains "digest 404 — says missing" "no route-revision digest available"
|
|
189
|
+
|
|
190
|
+
# 6. A minimum is declared but CORE_ROUTE_REVISION_URL is not set — must fail
|
|
191
|
+
# without invoking curl (misconfiguration, not "core is ready").
|
|
192
|
+
_write_stub_never_called
|
|
193
|
+
_run -u CORE_ROUTE_REVISION_URL -- CORE_MIN_ROUTE_REVISION=42
|
|
194
|
+
_assert_exit "minimum declared but URL unset" 1
|
|
195
|
+
|
|
196
|
+
echo
|
|
197
|
+
if [ "$FAILURES" -eq 0 ]; then
|
|
198
|
+
echo "core-revision-preflight.test.sh: all checks passed."
|
|
199
|
+
exit 0
|
|
200
|
+
else
|
|
201
|
+
echo "core-revision-preflight.test.sh: $FAILURES check(s) failed."
|
|
202
|
+
exit 1
|
|
203
|
+
fi
|