@biffo/cli 0.291.6 → 0.293.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.
@@ -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` so a failed infra job SKIPS the
372
- # app job. Reporting that skip as "deployed nothing" would bury the
373
- # actual cause under a second, wrong error. Defer to the real one.
374
- case "${INFRA}:${APP}" in
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
package/dist/index.js CHANGED
@@ -7824,7 +7824,8 @@ var IndexDefinitionSchema = z7.object({
7824
7824
  });
7825
7825
  var PermissionRuleSchema = z7.object({
7826
7826
  allowed: z7.boolean().default(false),
7827
- required_role: z7.array(z7.string()).default([])
7827
+ required_role: z7.array(z7.string()).default([]),
7828
+ permission_code: z7.string().default("")
7828
7829
  }).strict();
7829
7830
  var TablePermissionsSchema = z7.object({
7830
7831
  list: PermissionRuleSchema.default({}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.291.6",
3
+ "version": "0.293.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",