Skip to content

Commit 4b47855

Browse files
yinlianghuiclaude
andauthored
fix(scripts): bump-objectui.sh runs on bash 3.2 — the #10495 pin warning is macOS-reachable again (#12142)
`report_objectui_reachability` used the bash 4 builtin `mapfile` to enumerate the branches that contain an off-main pin. `/usr/bin/env bash` is bash 3.2.57 on macOS, and this script is run BY HAND by an operator — the pin-bump procedure in docs/releases-maintenance.md has no CI path — so the builtin fails on the ordinary host, not a fringe one. It also fails on the one branch that must not fail. `mapfile` is reached only after the verdict is "NOT on origin/main", so the #10495 branch-reachability warning is the single thing that shell cannot deliver. Measured on a shell with the builtin disabled: the run dies at status 127 with `mapfile: command not found` as its ONLY output, before the pin is written — the operator is handed a bare builtin error where the warning belongs, and its obvious workaround (hand-editing .objectui-sha) walks around every guard in the file. Replaced both sites with the bash-3.2 read loop already idiomatic in scripts/pm/os-verify-lock.sh, which carries this repo's bash-3.2 floor doctrine. The `if` rather than a trailing `[[ … ]] &&` is load-bearing under `set -e`. CI runs bash 5, so neither the defect nor the repair is observable in a normal CI run. The digest self-test therefore gains R7: a static scan for bash 4+/5 constructs, a vacuity check that the simulation really removes the builtin, and a real re-run of the R2 reachability case under `enable -n mapfile readarray`. Ablation: restoring `mapfile` turns R7, R7b and R7c red (127); restore verified byte-identical. Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6dd3e69 commit 4b47855

2 files changed

Lines changed: 119 additions & 3 deletions

File tree

‎scripts/bump-objectui.sh‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,37 @@ report_objectui_reachability() {
320320

321321
# Enumerated only on this branch: with ~941 branches a --contains walk is not
322322
# free, and the healthy path must not pay for it.
323+
#
324+
# READ LOOP, NOT `mapfile` — THE BASH 3.2 FLOOR. `mapfile`/`readarray` are
325+
# bash 4 builtins, and `/usr/bin/env bash` is bash 3.2.57 on macOS (Apple has
326+
# not shipped bash 4+ for licensing reasons). This script is run BY HAND, BY
327+
# AN OPERATOR, on a laptop — the pin-bump procedure in
328+
# `docs/releases-maintenance.md` has no CI path — so a bash-4 builtin here
329+
# does not fail on a fringe host, it fails on the ordinary one. And it fails
330+
# on exactly the branch that must not fail: `mapfile` is only ever reached
331+
# once the verdict is "NOT on origin/main", i.e. the #10495 warning is the
332+
# single thing this shell cannot deliver. Measured with `mapfile` disabled:
333+
# `mapfile: command not found`, then `set -e` kills the run at status
334+
# 127 BEFORE the pin is written, and the operator is handed a bare builtin
335+
# error where the warning should have been — an error whose obvious remedy
336+
# (edit `.objectui-sha` by hand) walks around every guard in this file.
337+
# CI runs bash 5, so neither the defect nor this repair is observable in a
338+
# normal CI run; the digest self-test pins it with the builtin disabled
339+
# (`enable -n mapfile readarray` via `BASH_ENV`) plus a static scan.
340+
# Keep this loop bash-3.2-clean: no `mapfile`, no `readarray`, no
341+
# `declare -A`, no `${x^^}`/`${x,,}`. The `if` (rather than `[[ … ]] &&`) is
342+
# load-bearing under `set -e`: a trailing false `&&`-list would make the
343+
# whole `while` return 1 and kill the run on an empty ref list.
323344
local -a local_refs=() remote_refs=()
324-
mapfile -t local_refs < <(
345+
local ref_line=''
346+
while IFS= read -r ref_line; do
347+
if [[ -n "$ref_line" ]]; then local_refs+=("$ref_line"); fi
348+
done < <(
325349
git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/heads 2>/dev/null || true
326350
)
327-
mapfile -t remote_refs < <(
351+
while IFS= read -r ref_line; do
352+
if [[ -n "$ref_line" ]]; then remote_refs+=("$ref_line"); fi
353+
done < <(
328354
git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/remotes 2>/dev/null || true
329355
)
330356

‎scripts/objectui-changeset-digest.mjs‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2505,14 +2505,17 @@ function selfTest() {
25052505
return dir;
25062506
};
25072507
// Offline by construction — a self-test must never reach the network.
2508-
const runBump = (fwDir, uiRoot, args) =>
2508+
// `extraEnv` exists for R7 below, which re-runs one of these cases under a
2509+
// shell with the bash 4 builtins taken away.
2510+
const runBump = (fwDir, uiRoot, args, extraEnv = {}) =>
25092511
spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), ...args], {
25102512
encoding: 'utf8',
25112513
env: {
25122514
...process.env,
25132515
OBJECTUI_ROOT: uiRoot,
25142516
OBJECTUI_NO_DEEPEN: '1',
25152517
GIT_TERMINAL_PROMPT: '0',
2518+
...extraEnv,
25162519
},
25172520
});
25182521
const mkUi = (name) => {
@@ -2720,6 +2723,93 @@ function selfTest() {
27202723
r6.stderr,
27212724
);
27222725

2726+
// --- R7: the bash 3.2 floor --------------------------------------------
2727+
//
2728+
// `bump-objectui.sh` is run BY HAND by an operator (docs/releases-
2729+
// maintenance.md), and `/usr/bin/env bash` is bash 3.2.57 on macOS. Every
2730+
// case above ran on whatever bash the host has — bash 5 in CI — so the
2731+
// whole R1..R6 block is blind to a bash-4-only construct: it went green on
2732+
// a script that could not complete a single one of these runs on a Mac.
2733+
// Measured: with `mapfile` unavailable, R2/R2b/R2c/R2d/R3/R3b/R6 all fail,
2734+
// two of them with `status=127`, and the operator run dies at 127 with
2735+
// `mapfile: command not found` as its ONLY output — the #10495 warning,
2736+
// which is reached on no other branch, cannot print at all.
2737+
//
2738+
// Both halves are needed and they catch different things: the static scan
2739+
// sees parse-level constructs that `enable -n` cannot simulate, and the
2740+
// simulated run proves the real code path completes without the builtins.
2741+
const bash4Constructs = new RegExp(
2742+
[
2743+
'exec\\s+\\{[A-Za-z_]', // bash 4.1 fd auto-allocation
2744+
'\\$\\{[A-Za-z_][A-Za-z0-9_]*(\\[[^\\]]*\\])?(\\^|,)', // bash 4.0 ${x^^} / ${x,,}
2745+
'(declare|local|typeset)\\s+-[A-Za-z]*A\\s', // bash 4.0 associative arrays
2746+
'(mapfile|readarray)\\s', // bash 4.0 builtins — the one this card is about
2747+
'&>>', // bash 4.0 append-both redirection
2748+
'EPOCH(SECONDS|REALTIME)', // bash 5.0 variables
2749+
].join('|'),
2750+
);
2751+
const bumpSrc = readFileSync(join(__dirname, 'bump-objectui.sh'), 'utf8');
2752+
// Comments are exempt: this file has to NAME the constructs it refuses in
2753+
// order to explain why, and the simulated run below covers what actually
2754+
// executes.
2755+
const bash4Hits = bumpSrc
2756+
.split('\n')
2757+
.map((line, i) => [i + 1, line])
2758+
.filter(([, line]) => !/^\s*#/.test(line))
2759+
.filter(([, line]) => bash4Constructs.test(line))
2760+
.map(([n, line]) => `${n}: ${line.trim()}`);
2761+
check(
2762+
'#12071 R7 bump-objectui.sh names no bash 4+/5 construct (mapfile, ${x^^}, declare -A, &>>, EPOCH*)',
2763+
bash4Hits.length === 0,
2764+
bash4Hits.join('\n'),
2765+
);
2766+
2767+
// Bash 3.2's world imposed on this host: `enable -n` really does make
2768+
// `mapfile`/`readarray` "command not found" — the same status 127 macOS
2769+
// produces — and BASH_ENV is sourced by every non-interactive bash, so the
2770+
// script under test inherits it through `spawnSync`.
2771+
const noBash4 = join(tmp, 'no-bash4-builtins.sh');
2772+
writeFileSync(noBash4, 'enable -n mapfile readarray 2>/dev/null\n');
2773+
// The instrument must not be vacuous. If BASH_ENV were ever ignored (a
2774+
// posix-mode bash, a future harness change), R7b would pass by proving
2775+
// nothing, so the disabling is measured on a probe FIRST, both ways.
2776+
const mapfileProbe = join(tmp, 'mapfile-probe.sh');
2777+
writeFileSync(mapfileProbe, 'mapfile -t x < <(printf "a\\n") && echo MAPFILE-WORKS\n');
2778+
const probePlain = spawnSync('bash', [mapfileProbe], { encoding: 'utf8' });
2779+
const probeSim = spawnSync('bash', [mapfileProbe], {
2780+
encoding: 'utf8',
2781+
env: { ...process.env, BASH_ENV: noBash4 },
2782+
});
2783+
check(
2784+
'#12071 R7a the simulated-3.2 harness really removes the builtin (else R7b proves nothing)',
2785+
probePlain.stdout.includes('MAPFILE-WORKS') &&
2786+
!probeSim.stdout.includes('MAPFILE-WORKS') &&
2787+
probeSim.stderr.includes('mapfile'),
2788+
`plain=${probePlain.stdout.trim()} sim.out=${probeSim.stdout.trim()} sim.err=${probeSim.stderr.trim()}`,
2789+
);
2790+
2791+
// The R2 shape again — pushed, never merged — through a shell that has no
2792+
// bash 4 builtins. This is the operator's macOS run, and the assertion is
2793+
// the WARNING, not merely a zero exit: the defect's signature was a run
2794+
// that produced the builtin error INSTEAD of the warning.
2795+
const fwNoB4 = mkFramework('fw-reach-nobash4', rBase);
2796+
const r7 = runBump(fwNoB4, uiR.dir, ['--no-commit', rPushed], { BASH_ENV: noBash4 });
2797+
check(
2798+
'#12071 R7b on a shell without bash 4 builtins the #10495 warning still fires, in full',
2799+
r7.stderr.includes('is NOT reachable from origin/main') &&
2800+
r7.stderr.includes('origin/feature/pushed-never-merged') &&
2801+
r7.stderr.includes('It IS pushed, but only onto branch(es) that have not merged'),
2802+
`status=${r7.status}\nSTDERR:${r7.stderr}`,
2803+
);
2804+
check(
2805+
'#12071 R7c … and the run completes as a warning, not a 127: exit 0, pin written, verdict on stdout',
2806+
r7.status === 0 &&
2807+
!r7.stderr.includes('command not found') &&
2808+
readFileSync(join(fwNoB4, '.objectui-sha'), 'utf8').trim() === rPushed &&
2809+
r7.stdout.includes('(NOT on origin/main)'),
2810+
`status=${r7.status}\nSTDOUT:${r7.stdout}\nSTDERR:${r7.stderr}`,
2811+
);
2812+
27232813
} finally {
27242814
rmSync(tmp, { recursive: true, force: true });
27252815
}

0 commit comments

Comments
 (0)