Conversation
The tools/get_*.sh scripts each carried their own copy of the same shell logic: portable_realpath in nine of them, the dirty-checkout guard in seven, the --force argument split in six, and the GitHub SSH probe in four, plus the repo-root derivation in nearly all of them. A fix to one copy left the others untouched, which is how a portable_realpath that rejects a path git clone has yet to create survived in nine places at once. tools/_get_common.sh now holds one copy of each helper, and every script sources it through its own directory, so a run by path (the docs, install.sh, the CI setup action, and proteus install-all through data.py) finds it without resolving a path first. A script whose library is absent stops with the missing file named instead of continuing with undefined helpers. The shared portable_realpath resolves a destination that does not exist yet, which is what an install path is before the clone, and get_socrates.sh stops when its install path cannot be resolved rather than passing an empty work-tree name to git clone. Variations that differ per script are parameters, not separate copies: guard_dirty_checkout takes a git pathspec so get_socrates.sh keeps excluding its regenerable make/Mk_cmd, get_parse_args reports both the --force switch and the optional install path, and the SSH probe honours GIT_SSH_COMMAND for every caller rather than only for SOCRATES. The helpers target bash 3.2 and behave the same with or without set -euo pipefail, because the scripts differ on that. Destinations are unchanged, with one exception: get_agni.sh and get_spider.sh now derive the checkout root through the same symlink-resolving path as the other scripts, so a checkout reached through a symlink resolves to the same root everywhere.
The cases for the install scripts read tools/_get_common.sh rather than carrying their own copy of the shell under test, so a change to a helper re-runs through them. They pin what a shared helper can silently change: the checkout root derived from the library's own location, the --force and install-path split in either argument order, the refusal to delete a checkout holding local work and the pathspec exclusion get_socrates.sh passes it, the SSH probe exit codes and its GIT_SSH_COMMAND override, the https-to-SSH URL rewrite, and a missing module pin stopping the install. Each runs under a plain shell and under set -euo pipefail, which is the split across the scripts themselves. Two invariants stand behind the rest: no get_*.sh carries a private copy of a helper, and every script that calls one also sources the file defining it. A script that grows a private copy again is outside the reach of these cases, which is what let the same four-line path fix be needed in nine places. Each script also runs whole against a stubbed git and ssh in a throwaway checkout, pinning the destination it resolves and the transport it selects, including a SOCRATES install path that does not exist yet and the https fallback when no key is accepted.
The cache key for the compiled SOCRATES tree hashes the install script, so a reworded build does not restore a tree the previous one produced. The script now sources tools/_get_common.sh, so a change to the SSH probe, the checkout guard, or the pin lookup left the key unchanged and would have restored a stale tree with nothing reporting the mismatch. The key hashes both files, and a case pins that it names every file the build step reads.
The dirty-checkout guard read both probes through `git ... | head -1`, whose status is git's under `set -o pipefail` and head's without it. A checkout with an unborn HEAD or an incomplete .git makes `git log HEAD` exit 128, so the same state stopped get_boreas.sh and get_vulcan.sh with no message at all and was silently deleted by the other six, staged work included. Both probes now run unpiped, bounded by git's own -1, and a probe that cannot run is reported with the checkout kept and --force named. The guard reaches the same verdict in either shell, which is what the library documents. Alongside: get_parse_args declares its loop variable, so it cannot clobber a caller's `arg`, and the aragog and Zalmoxis scripts quote the work path they delete and the root they return to. The cases behind this: the guard runs under both shells rather than only a plain one, and a checkout git cannot report on is pinned as kept in both. The two invariants that stand behind the extraction are sharpened, because both could be defeated by shell they were meant to catch: the private-copy scan reads the function names out of the library and matches every definition spelling bash accepts, including a copy that shadows the library in a script that does source it, and matches the dirty test and the SSH probe as commands rather than as text, so a message that names ssh is not mistaken for a probe; the source-line invariant compares line order, since a call above the source line is the silent no-op it warns about. The whole-script clone cases now run from outside the throwaway checkout, so a destination taken from the working directory can no longer coincide with the expected path.
The helper shares a namespace with every script that sources it, and `arg` is an ordinary name for a script to use, so a leaked loop variable would overwrite the caller's value with the last argument, silently and only when arguments are passed.
|
|
||
| import pytest | ||
|
|
||
| pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #864 +/- ##
=======================================
Coverage 93.45% 93.46%
=======================================
Files 113 113
Lines 16797 16797
Branches 2992 2992
=======================================
+ Hits 15698 15699 +1
+ Misses 1093 1092 -1
Partials 6 6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
nichollsh
approved these changes
Sep 9, 2026
nichollsh
left a comment
Member
There was a problem hiding this comment.
This is great and makes the code much easier to maintain. Thanks, @egpbos, for cleaning these up. I have tested this on my laptop (Fedora 44) and it seems to work well. That is, the scripts download/compile the respective modules as before.
Looks good to merge into main.
Do we want to perform a similar overhaul to the get_* scripts in the submodules?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The
tools/get_*.shinstall scripts each carried their own copy of the sameshell logic. Measured on
main:portable_realpathin nine scripts, thedirty-checkout guard in seven, the
--forceargument split in six, the GitHubSSH probe in four, and the repo-root derivation in nearly all of them. A fix to
one copy left the rest untouched, which is how a
portable_realpaththatrejects a path
git clonehas yet to create came to need the same four-linefix in nine places (#862).
tools/_get_common.shnow holds one copy of each helper:portable_realpathproteus_root,proteus_tools_dirget_parse_args--forceloop in 6 scripts and the flag-plus-path split in 2guard_dirty_checkoutgithub_use_sshssh -T git@github.comprobe in 4 scripts, in 3 different spellingsgithub_ssh_urlresolve_module_pinEvery script sources it through its own directory:
Every caller runs these scripts by path (the installation and cluster docs,
install.sh, thesetup-proteuscomposite action,ci-pr-checks.yml, andproteus install-allthroughsrc/proteus/utils/data.py), so${BASH_SOURCE[0]}locates the library without needing a resolved path first.A script whose library is absent stops with the missing file named rather than
continuing with undefined helpers.
Variations stay variations, as parameters rather than separate copies:
guard_dirty_checkouttakes a git pathspec, soget_socrates.shkeepsexcluding its regenerable
make/Mk_cmdfrom the dirty test.get_parse_argsreports both the--forceswitch and the optional installpath, in either order, which covers the six flag-only scripts and the two
that also accept a destination.
GIT_SSH_COMMANDfor every script, not only forSOCRATES; CI already relies on that for the SOCRATES build and now gets it
everywhere.
The helpers target bash 3.2 (the version macOS ships) and behave the same with
or without
set -euo pipefail, because the scripts differ on that andget_socrates.shenables it partway through. The two helpers that stop theinstall (
guard_dirty_checkout,resolve_module_pin) are documented asplain-command calls, since an
exitinside a command substitution would onlyleave the subshell.
Three behaviour changes come with the extraction, all intended:
portable_realpathresolves a destination that does not existyet, and
get_socrates.shstops when its install path cannot be resolved.This is the fix from Resolve a get_* install path that does not exist yet #862, applied to the single copy: on
main,bash tools/get_socrates.sh /some/new/pathreachesgit clonewith an emptywork-tree name. That PR can be closed in favour of this one; if it merges
first, the conflict resolves by dropping the nine copies it touched.
get_agni.shandget_spider.shderived the checkout root withcd ... && pwd, which keeps a symlinked path, while the other nine usedrealpath, which resolves it. All of them now resolve it, so a checkoutreached through a symlink lands in the same root everywhere instead of
installing AGNI under the logical path and aragog under the physical one.
git ... | head -1,whose exit status is git's under
set -o pipefailandhead's without it.A checkout with an unborn HEAD or an incomplete
.gitmakesgit log HEADexit 128, so the same state stoppedget_boreas.shandget_vulcan.shwith no message at all and was silently deleted by theother six, staged work included. Both probes now run unpiped, bounded by
git's own
-1, and a probe that cannot run is reported with the checkoutkept and
--forcenamed. The verdict is the same in either shell, whichis what the library documents.
Two smaller changes in the scripts the extraction rewrites:
get_aragog.shand
get_zalmoxis.shquote the work path theyrm -rfand the root theycdback to (both were unquoted, and the root is now a resolved path that adirectory name with a space would have split), and
get_lovepy.shstops ona missing LovePy pin rather than passing an empty
url=/rev=toPkg.add.Also in this PR: the SOCRATES cache key in the composite action hashed only
tools/get_socrates.sh, so a change to the shared helpers would have restoreda tree built by the previous version with nothing reporting the mismatch. The
key now hashes the library too.
docs/How-to/development_standards.mdgains a short "Module install scripts"section: what the library provides, the bootstrap a new script starts from, and
the rule that a variation becomes a parameter rather than a second copy. That
replaces the "keep this guard in sync across the get_* scripts" comments the
duplicated blocks carried.
Net across the
tools/get_*.shscripts: 374 lines removed, 170 added. Thelibrary that replaces them is 169 lines, about half of it comment. The rest of
the diff is the tests (864 added, 89 removed) and the docs section.
Validation of changes
Test configuration: macOS 26.6.2 (arm64), bash 3.2.57, which is the only
bash on PATH here, so the bash 3.2 target is what actually ran, and Python
3.12 in the project environment.
Behaviour, before against after. Each
tools/get_*.shran whole in athrowaway checkout with
gitandsshstubbed on PATH, once with the SSHprobe accepting the key and once refusing it, recording every git call. The
same harness ran against
main's scripts. Every clone destination andtransport is identical, with the two destination exceptions above:
get_socrates.sh <path that does not exist>:mainreachesgit clone <url>with an empty destination; this branch reachesgit clone <url> <resolved path>.get_agni.sh: the destination is the symlink-resolved root rather than thelogical one (same directory, different spelling).
PETSC_DIR,PETSC_ARCHand the SPIDER default destination are byte-identicalto
mainin both the default and the custom-path cases.Tests,
tests/tools/test_install_scripts.py:tools/_get_common.shinstead of copying theshell under test, so a change to a helper re-runs through all of them. Each
helper case runs under a plain shell and under
set -euo pipefail, thesplit across the scripts themselves.
from the library's own location rather than the CWD; the
--forceandinstall-path split in either order and with no arguments at all (the edge
case an unguarded
"$@"would abort underset -u); the guard's pathspecexclusion, including that the same modification blocks without the
exclusion; the SSH probe's exit-code mapping (1 accepted, 255 refused, 0
refused) and its
GIT_SSH_COMMANDoverride; that the probe's own banner onstdout cannot contaminate the answer; the URL rewrite leaving a non-github
and an already-SSH URL alone; a missing module pin stopping the install; a
checkout whose state git cannot report being kept in either shell;
argument parsing leaving a caller's own variables alone; and each script's
clone destination and transport end to end, run from outside the throwaway
checkout so that a destination taken from the working directory cannot
coincide with the expected path.
identical text: no
get_*.shcarries a private copy of a helper, and everyscript that calls one sources the file defining it first. Both read the
helper names out of the library rather than listing them, so a rename
cannot drop a helper out of the scan. The private-copy scan matches every
definition spelling bash accepts (
name() {,name () {,function name {), including a copy that shadows the library in a scriptthat does source it, and matches the dirty test and the SSH probe as
commands rather than as text, so
get_spider.shnaming the probe in itstroubleshooting output is not mistaken for a copy. The source-line
invariant compares line order, because a call above the
sourceline isthe silent no-op it warns about.
and fails under it: reintroducing a private
portable_realpathin threespellings, including in a script that sources the library; a private
github_use_ssh; the dirty test in its-unoshort form; dropping asourceline and moving a call above it; dropping the bootstrap'smissing-file check; making a destination CWD-relative (11 of the 12
whole-script cases fail, the twelfth passing an absolute path); restoring
the guard's piped probe; leaking the parser's loop variable; and dropping
the library from the cache key.
Suite runs:
pytest tests/tools/ -q: 272 passed.pytest -m "unit and not skip and not slow and not integration" --ignore=tests/examples: 3025 passed, 45 skipped, 9 failed. The same 9 failon
mainin this environment (all intests/atmos_clim/test_janus.py,unrelated to shell scripts).
pytest tests/test_doctor.py tests/test_cli.py tests/utils/: 807 passed,5 skipped. These are the suites covering the callers that invoke the scripts.
ruff check src/ tests/andruff format --check src/ tests/: clean.bash tools/validate_test_structure.sh: complete.python tools/check_test_quality.py --check: no violations in the touchedfile (its pre-existing regressions are in other files).
bash -non everytools/*.sh: clean.Observed but not changed here: in
get_vulcan.sh, thefloor=$(grep -oE ... | head -1 | sed ...)pipeline runs underset -euo pipefail, so apyproject.tomlwithout anfwl-vulcan>=pin killsthe script at that line and the
WARNING: could not read fwl-vulcan floorbranch below it is unreachable. That predates this PR and is unrelated to the
shared helpers, so it is left for its own change.
Not verified:
shellcheckis not installed on this host, so the scripts were not lintedwith it.
git,ssh, and the compilers were stubbed,so the SOCRATES, SPIDER and PETSc build steps past the clone are unexercised.
CI exercises the SOCRATES path for real.
realpathon a missing parent was not exercised on a Linux host, onlyits documented behaviour. The fix does not depend on which of the two
refuses, since any refusal falls through to
python3.GIT_SSH_COMMANDunquoted so that its options reachssh as separate words, which is what
get_socrates.shdid before. An optionvalue containing a space therefore does not survive; the probe then exits
non-1 and the script falls back to https, which is safe but silent.
get_*.shthrough a symlink placed outsidetools/now stopswith the missing-library error, where before it resolved through
$0. Nocaller in the repo or the docs does that, and the failure is loud rather than
silent.
Checklist
in
docs/How-to/development_standards.md; the installation docs call thescripts by path, which is unchanged)
changed;
python3was already the fallback inside the helper)