From b65a4e3b69430c72cacafe3f0eccb7fee67fb57c Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 16:09:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20zsh=20support=20=E2=80=94=20port=20?= =?UTF-8?q?utilities=20to=20run=20under=20zsh's=20ksh=20emulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xsh runs imported utilities under zsh's ksh emulation (`emulate -L ksh`). Most utilities already work as-is; this fixes the constructs the emulation doesn't cover, and adds a test suite + CI exercising both shells. Code (function-type utilities; scripts/ always run via bash, so unaffected): - ${!var} name-indirection (value, ${!var+x} is-set, ${!var#prefix}, ${!arr[@]} array-form) -> portable `eval` — cfg/get, cfg/set, cfn/stack/ create+update, cfn/vpn/cluster+config. - ${!PREFIX@} variable-name listing -> zsh `parameters` association (eval-wrapped so bash never parses the zsh-only syntax) — cfn/vpn/config. - ${!#} -> ${*: -1}; positional ${!n} -> ${*:N:1}. - ${!arr[@]} index iteration over a contiguous array -> counted loop — cfn/vpn/ami. - FUNCNAME -> built from zsh `funcstack` where passed to x-trap-return — cfn/deploy, s3/test-upload-performance. - BASH_REMATCH -> `setopt bash_rematch` — s3/uri/parser. - `read -n N -p` -> zsh `read -k N "?prompt"` branch — ses/domain-dkim, ses/sandbox/move. - `status` local variable renamed (zsh ties `status` to $?, read-only) — cfn/__init__, cfn/stack/list+status/wait, ses/domain-dkim+domain-identity, ses/sandbox/move, rds/access. (`options` locals are left as-is: under the ksh emulation a function-local `declare -a options` safely shadows zsh's special parameter.) - cfn STACK_STATUS: the sparse `[code]=name` array (zsh can't represent sparse arrays; bash 3.2 has no associative arrays) is rebuilt as a flat (code, name) pairs list; the derived *_STABLE / *_SERVICEABLE / ... arrays are appended to (consumed by value, never by index, so equivalent). - s3/uri/parser: the -s/-a/-h/-p/-k branches delegated to `xsh /uri/parser` directly inside the getopts loop; under zsh OPTIND is shared between ksh-emulated functions, so the nested getopts reset this loop's OPTIND and spun forever. Capture the nested call in a subshell to isolate its OPTIND. Incidental: fixed a pre-existing bash syntax error in spt/create (a missing line-continuation made the function body swallow its closing brace, so the util failed to source under bash; the util is marked untested upstream). Tests: - New test.sh: import-smokes every function utility under the running shell, then asserts s3/uri/parser, s3/uri/translate, cfg/get (fixtures, throwaway HOME), and the cfn STACK_STATUS classification. Self-sources ~/.xshrc so it runs as a child of bash or zsh; uses an assert helper + failure counter rather than `set -e` (utils ending in a getopts loop return its non-zero status on success, which zsh's ERR_EXIT would trip inside `$()`). - New CI (.github/workflows/test.yml): os × {bash, zsh} matrix; loads xsh-lib/core then this library and runs test.sh. The zsh jobs are continue-on-error until a zsh-supporting xsh + xsh-lib/core are released. Verified: 14/14 assertions pass and all 76 function utilities import cleanly under both zsh 5.9 and macOS bash 3.2.57. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 52 +++++++ README.md | 3 + functions/cfg/get.sh | 21 ++- functions/cfg/set.sh | 8 +- functions/cfn/__init__.sh | 111 ++++++++------- functions/cfn/deploy.sh | 5 + functions/cfn/stack/create.sh | 12 +- functions/cfn/stack/list.sh | 6 +- functions/cfn/stack/status/wait.sh | 10 +- functions/cfn/stack/update.sh | 12 +- functions/cfn/vpn/ami.sh | 4 +- functions/cfn/vpn/cluster.sh | 18 ++- functions/cfn/vpn/config.sh | 32 ++++- functions/rds/access.sh | 8 +- functions/s3/test-upload-performance.sh | 5 + functions/s3/uri/parser.sh | 18 ++- functions/ses/domain-dkim.sh | 15 ++- functions/ses/domain-identity.sh | 6 +- functions/ses/sandbox/move.sh | 17 ++- functions/spt/create.sh | 4 +- test.sh | 171 ++++++++++++++++++++++++ 21 files changed, 426 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100755 test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d8a77fe --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +name: Test + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + # bash is the historical target; zsh is the macOS default login shell. + # Utilities run under zsh's ksh emulation (provided by xsh). + shell: [bash, zsh] + runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} / ${{ matrix.shell }} + # The zsh jobs install xsh from alexzhangs/xsh master and xsh-lib/core's + # latest stable tag, neither of which yet carries zsh support — so they + # stay red until those releases land. Keep them non-blocking until then. + # TODO: remove this once a zsh-supporting xsh + xsh-lib/core are released. + continue-on-error: ${{ matrix.shell == 'zsh' }} + + steps: + - name: Install zsh (Linux) + if: matrix.shell == 'zsh' && runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh + + - name: Install xsh + run: | + git clone --depth=50 --branch=master https://github.com/alexzhangs/xsh.git /tmp/xsh + bash /tmp/xsh/install.sh + + - name: Load dependency library xsh-lib/core + shell: bash + run: | + source ~/.xshrc + xsh load xsh-lib/core + + - name: Load library from current branch + shell: bash + run: | + source ~/.xshrc + xsh load -b "${{ github.head_ref || github.ref_name }}" xsh-lib/aws + + - name: Run tests (${{ matrix.shell }}) + # test.sh self-sources ~/.xshrc, so running it as a child of the matrix + # shell makes the utilities execute under that shell (bash, or zsh's + # ksh emulation). + run: ${{ matrix.shell }} ~/.xsh/repo/xsh-lib/aws/test.sh diff --git a/README.md b/README.md index d34c3ec..f4f5269 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ About xsh and its libraries, check out [xsh document](https://github.com/alexzha * 4.3.48 on Linux * 3.2.57 on macOS + The utilities also run under **zsh** (the default shell on modern macOS): + xsh executes them under zsh's ksh emulation. Tested with zsh 5.x. + 1. awscli Tested with `awscli 1.17.4`. diff --git a/functions/cfg/get.sh b/functions/cfg/get.sh index b8cb482..bf8d106 100644 --- a/functions/cfg/get.sh +++ b/functions/cfg/get.sh @@ -14,24 +14,29 @@ function get () { function __get () { declare profile=$1 \ - property varname + property varname value # output profile name as first field varname=${XSH_AWS_CFG_CONFIG_ENV_PREFIX}SECTIONS_${profile} - if [[ ! ${!varname+x} ]]; then # the variable was not declared + # `${!varname...}` is bash-only indirection; `eval` does it portably + # (the variable name is built from controlled prefixes + parsed + # section names, not arbitrary input). + if ! eval "[[ \${${varname}+x} ]]"; then # the variable was not declared varname=${XSH_AWS_CFG_CONFIG_ENV_PREFIX}SECTIONS_${profile#profile_} fi - printf "%s" "${!varname#profile }" + eval "value=\${${varname}#profile }" + printf "%s" "${value}" # output rest of properties as fields for property in "${XSH_AWS_CFG_PROPERTIES[@]:?}"; do varname=${property%.*}_SECTIONS_${profile}_VALUES_${property#*.} - if [[ ! ${!varname+x} ]]; then # the variable was not declared + if ! eval "[[ \${${varname}+x} ]]"; then # the variable was not declared varname=${property%.*}_SECTIONS_${profile#profile_}_VALUES_${property#*.} fi - printf ",%s" "${!varname}" + eval "value=\${${varname}}" + printf ",%s" "${value}" done # end of line @@ -46,8 +51,12 @@ function get () { # shellcheck disable=SC2125 declare varname=${XSH_AWS_CFG_CONFIG_ENV_PREFIX}SECTIONS[@] \ profile + declare -a profiles + # `${!varname}` array indirection (varname holds `NAME[@]`) is bash-only; + # `eval` expands the named array portably + eval "profiles=( \"\${${varname}}\" )" - for profile in "${!varname}"; do + for profile in "${profiles[@]}"; do if [[ -n ${name} ]]; then __get "${profile}" | grep "^${name}," || : else diff --git a/functions/cfg/set.sh b/functions/cfg/set.sh index 157eeac..2b0584c 100644 --- a/functions/cfg/set.sh +++ b/functions/cfg/set.sh @@ -23,13 +23,15 @@ function set () { return 255 fi - declare n=2 property # profile properties started at $2 + declare n=2 property value # profile properties started at $2 for property in "${XSH_AWS_CFG_PROPERTIES[@]:?}"; do property=${property#*.} + # `${!n}` (positional indirection) is bash-only; `${*:N:1}` is portable + value=${*:$((n)):1} if [[ ${name} == default ]]; then - aws configure set "${name}.${property:?}" "${!n}" + aws configure set "${name}.${property:?}" "${value}" else - aws configure set "${property:?}" "${!n}" --profile "${name}" + aws configure set "${property:?}" "${value}" --profile "${name}" fi n=$((n+1)) done diff --git a/functions/cfn/__init__.sh b/functions/cfn/__init__.sh index ced119d..45daa48 100644 --- a/functions/cfn/__init__.sh +++ b/functions/cfn/__init__.sh @@ -97,35 +97,41 @@ source /dev/stdin <<< "${XSH_AWS_CFN__CFG_PROPERTIES[@]:?}" #? ${index:3:1} in [2-5] : FAILED Status #? ${index:3:1} in [6-9] : INPROGRESS Status #? +# Status codes are a 4-digit classification key, decoded digit-by-digit below. +# Stored as (code, name) pairs rather than a `[code]=name` sparse array: zsh +# cannot represent sparse arrays (it pads up to the largest index), and bash 3.2 +# — the other supported target — has no associative arrays. The derived +# *_STABLE / *_SERVICEABLE / ... arrays built below are consumed by value +# (`[@]` and `/array/search`), never by index, so a flat list is equivalent. XSH_AWS_CFN__STACK_STATUS=( - [9607]=CREATE_IN_PROGRESS - [1403]=CREATE_FAILED - [1200]=CREATE_COMPLETE - - [9617]=ROLLBACK_IN_PROGRESS - [1413]=ROLLBACK_FAILED - [1410]=ROLLBACK_COMPLETE - - [9627]=DELETE_IN_PROGRESS - [1423]=DELETE_FAILED - [1221]=DELETE_COMPLETE - - [9637]=UPDATE_IN_PROGRESS - [9236]=UPDATE_COMPLETE_CLEANUP_IN_PROGRESS - [1230]=UPDATE_COMPLETE - [1433]=UPDATE_FAILED - [9437]=UPDATE_ROLLBACK_IN_PROGRESS - [1435]=UPDATE_ROLLBACK_FAILED - [9436]=UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS - [1430]=UPDATE_ROLLBACK_COMPLETE - - [9647]=REVIEW_IN_PROGRESS - - [9657]=IMPORT_IN_PROGRESS - [1250]=IMPORT_COMPLETE - [9457]=IMPORT_ROLLBACK_IN_PROGRESS - [1453]=IMPORT_ROLLBACK_FAILED - [1450]=IMPORT_ROLLBACK_COMPLETE + 9607 CREATE_IN_PROGRESS + 1403 CREATE_FAILED + 1200 CREATE_COMPLETE + + 9617 ROLLBACK_IN_PROGRESS + 1413 ROLLBACK_FAILED + 1410 ROLLBACK_COMPLETE + + 9627 DELETE_IN_PROGRESS + 1423 DELETE_FAILED + 1221 DELETE_COMPLETE + + 9637 UPDATE_IN_PROGRESS + 9236 UPDATE_COMPLETE_CLEANUP_IN_PROGRESS + 1230 UPDATE_COMPLETE + 1433 UPDATE_FAILED + 9437 UPDATE_ROLLBACK_IN_PROGRESS + 1435 UPDATE_ROLLBACK_FAILED + 9436 UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS + 1430 UPDATE_ROLLBACK_COMPLETE + + 9647 REVIEW_IN_PROGRESS + + 9657 IMPORT_IN_PROGRESS + 1250 IMPORT_COMPLETE + 9457 IMPORT_ROLLBACK_IN_PROGRESS + 1453 IMPORT_ROLLBACK_FAILED + 1450 IMPORT_ROLLBACK_COMPLETE ) #? Generate Variables: @@ -150,42 +156,49 @@ XSH_AWS_CFN__STACK_STATUS=( #? XSH_AWS_CFN__STACK_STATUS_FAILED #? XSH_AWS_CFN__STACK_STATUS_INPROGRESS #? -declare index -for index in "${!XSH_AWS_CFN__STACK_STATUS[@]}"; do +declare __i index __status +# iterate the (code, name) pairs; append each name to the matching derived +# arrays. Appending (rather than `arr[code]=name`) keeps the derived arrays +# contiguous, which is required under zsh and equivalent for the value-based +# consumers. +for (( __i = 0; __i < ${#XSH_AWS_CFN__STACK_STATUS[@]}; __i += 2 )); do + index=${XSH_AWS_CFN__STACK_STATUS[__i]} + __status=${XSH_AWS_CFN__STACK_STATUS[$((__i + 1))]} + # XSH_AWS_CFN__STACK_STATUS_STABLE # XSH_AWS_CFN__STACK_STATUS_UNSTABLE case ${index:0:1} in 1) - XSH_AWS_CFN__STACK_STATUS_STABLE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_STABLE+=("${__status}") ;; 9) - XSH_AWS_CFN__STACK_STATUS_UNSTABLE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_UNSTABLE+=("${__status}") ;; esac - + # XSH_AWS_CFN__STACK_STATUS_SATISFIED # XSH_AWS_CFN__STACK_STATUS_UNSATISFIED # XSH_AWS_CFN__STACK_STATUS_SATISFYING case ${index:1:1} in 2) - XSH_AWS_CFN__STACK_STATUS_SATISFIED[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_SATISFIED+=("${__status}") ;; 4) - XSH_AWS_CFN__STACK_STATUS_UNSATISFIED[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_UNSATISFIED+=("${__status}") ;; 6) - XSH_AWS_CFN__STACK_STATUS_SATISFYING[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_SATISFYING+=("${__status}") ;; esac # XSH_AWS_CFN__STACK_STATUS_SERVICEABLE # XSH_AWS_CFN__STACK_STATUS_UNSERVICEABLE - case $((index%2)) in + case $((index % 2)) in 0) - XSH_AWS_CFN__STACK_STATUS_SERVICEABLE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_SERVICEABLE+=("${__status}") ;; 1) - XSH_AWS_CFN__STACK_STATUS_UNSERVICEABLE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_UNSERVICEABLE+=("${__status}") ;; esac @@ -197,22 +210,22 @@ for index in "${!XSH_AWS_CFN__STACK_STATUS[@]}"; do # XSH_AWS_CFN__STACK_STATUS_IMPORT case ${index:2:1} in 0) - XSH_AWS_CFN__STACK_STATUS_CREATE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_CREATE+=("${__status}") ;; 1) - XSH_AWS_CFN__STACK_STATUS_ROLLBACK[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_ROLLBACK+=("${__status}") ;; 2) - XSH_AWS_CFN__STACK_STATUS_DELETE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_DELETE+=("${__status}") ;; 3) - XSH_AWS_CFN__STACK_STATUS_UPDATE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_UPDATE+=("${__status}") ;; 4) - XSH_AWS_CFN__STACK_STATUS_REVIEW[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_REVIEW+=("${__status}") ;; 5) - XSH_AWS_CFN__STACK_STATUS_IMPORT[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_IMPORT+=("${__status}") ;; esac @@ -221,14 +234,14 @@ for index in "${!XSH_AWS_CFN__STACK_STATUS[@]}"; do # XSH_AWS_CFN__STACK_STATUS_INPROGRESS case ${index:3:1} in [0,1]) - XSH_AWS_CFN__STACK_STATUS_COMPLETE[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_COMPLETE+=("${__status}") ;; [2-5]) - XSH_AWS_CFN__STACK_STATUS_FAILED[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_FAILED+=("${__status}") ;; [6-9]) - XSH_AWS_CFN__STACK_STATUS_INPROGRESS[index]=${XSH_AWS_CFN__STACK_STATUS[index]} + XSH_AWS_CFN__STACK_STATUS_INPROGRESS+=("${__status}") ;; esac done -unset index +unset __i index __status diff --git a/functions/cfn/deploy.sh b/functions/cfn/deploy.sh index 6726b75..70dc771 100644 --- a/functions/cfn/deploy.sh +++ b/functions/cfn/deploy.sh @@ -270,6 +270,11 @@ #? @xsh imports aws/cfn/stack/create aws/cfn/stack/update aws/cfn/stack/delete aws/cfn/stack/event aws/cfn/stack/log #? function deploy () { + # zsh doesn't populate FUNCNAME; build it from zsh's funcstack so the + # `${FUNCNAME[0]}` references below (passed to x-trap-return to scope the + # trap to this function) work. No-op under bash, which sets FUNCNAME natively. + # shellcheck disable=SC2034,SC2154 + [[ -z ${ZSH_VERSION-} ]] || declare -a FUNCNAME=( "${funcstack[@]}" ) function __check_config_version__ () { declare version=${1:?} diff --git a/functions/cfn/stack/create.sh b/functions/cfn/stack/create.sh index d4bd3de..985b2d8 100644 --- a/functions/cfn/stack/create.sh +++ b/functions/cfn/stack/create.sh @@ -105,15 +105,17 @@ function create () { "${pass_options[@]}" ) - declare name + declare name value for name in template stack_policy; do - if [[ -n ${!name} ]]; then - case $(xsh /uri/parser -s "${!name}" | xsh /string/pipe/lower) in + # `${!name}` indirection is bash-only; `eval` reads the named var portably + eval "value=\${${name}}" + if [[ -n ${value} ]]; then + case $(xsh /uri/parser -s "${value}" | xsh /string/pipe/lower) in http|https) - options+=( "--${name//_/-}-url" "${!name}" ) + options+=( "--${name//_/-}-url" "${value}" ) ;; '') - options+=( "--${name//_/-}-body" "$(cat "${!name}")" ) + options+=( "--${name//_/-}-body" "$(cat "${value}")" ) ;; *) return 255 diff --git a/functions/cfn/stack/list.sh b/functions/cfn/stack/list.sh index 33ad595..666a7d2 100644 --- a/functions/cfn/stack/list.sh +++ b/functions/cfn/stack/list.sh @@ -37,7 +37,7 @@ function list () { declare OPTIND OPTARG opt - declare -a region_opt status query output + declare -a region_opt __status query output xsh imports /util/getopts/extra @@ -49,7 +49,7 @@ function list () { s) x-util-getopts-extra "$@" # shellcheck disable=SC2207 - status=(--stack-status-filter "${OPTARG[@]:?]}") + __status=(--stack-status-filter "${OPTARG[@]:?]}") ;; q) # the selector `|[]` strips the outer layer of `[]` in the result @@ -68,5 +68,5 @@ function list () { # list stacks aws "${region_opt[@]}" "${query[@]}" "${output[@]}" \ cloudformation list-stacks \ - "${status[@]}" + "${__status[@]}" } diff --git a/functions/cfn/stack/status/wait.sh b/functions/cfn/stack/status/wait.sh index d3c224a..1f2a315 100644 --- a/functions/cfn/stack/status/wait.sh +++ b/functions/cfn/stack/status/wait.sh @@ -75,19 +75,19 @@ function wait () { declare timeout_epoch timeout_epoch=$(($(date +%s) + timeout)) - declare status left_epoch + declare __status left_epoch while [[ 1 ]]; do - status=$(xsh aws/cfn/stack/status/get "${region_opt[@]}" -s "$stack_name") - printf "%s: %s ..." "$(date '+%F %T')" "${status:-NULL}" + __status=$(xsh aws/cfn/stack/status/get "${region_opt[@]}" -s "$stack_name") + printf "%s: %s ..." "$(date '+%F %T')" "${__status:-NULL}" left_epoch=$((timeout_epoch - $(date +%s))) # exit loop if match expecting status - if [[ $status == $target_status ]]; then + if [[ $__status == $target_status ]]; then printf " [ok]\n" return # exit loop if reachs stable status - elif [[ -n $(xsh /array/search XSH_AWS_CFN__STACK_STATUS_STABLE "$status") ]]; then + elif [[ -n $(xsh /array/search XSH_AWS_CFN__STACK_STATUS_STABLE "$__status") ]]; then printf " [not match]\n" return 255 # exit loop if time is out diff --git a/functions/cfn/stack/update.sh b/functions/cfn/stack/update.sh index cc20cff..7e7ee3a 100644 --- a/functions/cfn/stack/update.sh +++ b/functions/cfn/stack/update.sh @@ -136,15 +136,17 @@ function update () { options+=( --use-previous-template ) fi - declare name + declare name value for name in template stack_policy stack_policy_during_update; do - if [[ -n ${!name} ]]; then - case $(xsh /uri/parser -s "${!name}" | xsh /string/lower) in + # `${!name}` indirection is bash-only; `eval` reads the named var portably + eval "value=\${${name}}" + if [[ -n ${value} ]]; then + case $(xsh /uri/parser -s "${value}" | xsh /string/lower) in http|https) - options+=( "--${name//_/-}-url" "${!name}" ) + options+=( "--${name//_/-}-url" "${value}" ) ;; '') - options+=( "--${name//_/-}-body" "$(cat "${!name}")" ) + options+=( "--${name//_/-}-body" "$(cat "${value}")" ) ;; *) return 255 diff --git a/functions/cfn/vpn/ami.sh b/functions/cfn/vpn/ami.sh index fdade1d..6e8e2cf 100644 --- a/functions/cfn/vpn/ami.sh +++ b/functions/cfn/vpn/ami.sh @@ -128,7 +128,9 @@ function ami () { declare regions index ami # shellcheck disable=SC2207 regions=( $(aws-region-list) ) - for index in "${!regions[@]}"; do + # `${!regions[@]}` (array indices) yields values under zsh, not indices; + # the array is contiguous, so a counted loop is portable + for (( index = 0; index < ${#regions[@]}; index++ )); do printf "." >&2 ami=$(__get_ami__ "${regions[index]}" | sed 's/ / /g') # indent level: -1 printf '"%s": %s' "${regions[index]}" "${ami:-{\}}" # ami: None ==> {} diff --git a/functions/cfn/vpn/cluster.sh b/functions/cfn/vpn/cluster.sh index db1d215..765f855 100644 --- a/functions/cfn/vpn/cluster.sh +++ b/functions/cfn/vpn/cluster.sh @@ -229,24 +229,30 @@ function cluster () { declare -a CONFIG_OPTIONS CREATE_OPTIONS UPDATE_OPTIONS DELETE_OPTIONS # create and/or update - declare operation operation_cluster_varname operation_options_varname + declare operation operation_cluster_varname operation_options_varname cluster_value + declare -a operation_options for operation in create update; do operation_cluster_varname="${operation}_cluster" operation_options_varname="$(x-string-upper "$operation")_OPTIONS[@]" - if [[ -n ${!operation_cluster_varname} ]]; then - __build_options__ "${!operation_cluster_varname}" "${stacks[*]}" "$region" + # `${!var}` scalar/array indirection is bash-only; `eval` is portable. + # The array form expands `NAME[@]` into separate elements in both shells. + eval "cluster_value=\${${operation_cluster_varname}}" + eval "operation_options=( \"\${${operation_options_varname}}\" )" + + if [[ -n ${cluster_value} ]]; then + __build_options__ "${cluster_value}" "${stacks[*]}" "$region" if [[ ${stacks[0]} == 0 && ${#stacks[@]} -gt 1 ]]; then # manager stack goes first aws-cfn-vpn-config -x 0 "${CONFIG_OPTIONS[@]}" - aws-cfn-vpn-deploy -x 0 "${!operation_options_varname}" + aws-cfn-vpn-deploy -x 0 "${operation_options[@]}" # node stacks goes next aws-cfn-vpn-config -x "${stacks[@]:1}" "${CONFIG_OPTIONS[@]}" - aws-cfn-vpn-deploy -x "${stacks[@]:1}" "${!operation_options_varname}" + aws-cfn-vpn-deploy -x "${stacks[@]:1}" "${operation_options[@]}" else aws-cfn-vpn-config -x "${stacks[@]}" "${CONFIG_OPTIONS[@]}" - aws-cfn-vpn-deploy -x "${stacks[@]}" "${!operation_options_varname}" + aws-cfn-vpn-deploy -x "${stacks[@]}" "${operation_options[@]}" fi fi done diff --git a/functions/cfn/vpn/config.sh b/functions/cfn/vpn/config.sh index e990970..13c2ecb 100644 --- a/functions/cfn/vpn/config.sh +++ b/functions/cfn/vpn/config.sh @@ -348,11 +348,13 @@ function config () { __set_to_prefix_if_prefix_is_empty__ XACVC_XACC_ STACK_NAME # update - declare var config_var + declare var config_var value for var in "${XSH_AWS_CFN_VPN__CONFIG_VARS[@]}"; do config_var=${var#XACVC_XACC_} xsh log info "> updating $config_var ..." - x-util-sed-inplace "s|^$config_var=[^\"]*|$config_var=${!var}|" "$file" + # `${!var}` indirection is bash-only; `eval` is portable + eval "value=\${${var}}" + x-util-sed-inplace "s|^$config_var=[^\"]*|$config_var=${value}|" "$file" done # shellcheck disable=SC2034 @@ -365,10 +367,22 @@ function config () { __unset_options_env_for_stack_type__ "$stack_type" # update OPTIONS - for var in "${!XACVC_XACC_OPTIONS_@}"; do + # `${!PREFIX@}` (variable names by prefix) is bash-only; zsh uses + # its `parameters` association. The zsh-only syntax is eval-wrapped + # so bash never parses it, and vice versa. + declare -a __optvars + if [[ -n ${ZSH_VERSION-} ]]; then + # shellcheck disable=SC3044 + zmodload zsh/parameter 2>/dev/null + eval '__optvars=( ${(k)parameters[(I)XACVC_XACC_OPTIONS_*]} )' + else + __optvars=( "${!XACVC_XACC_OPTIONS_@}" ) + fi + for var in "${__optvars[@]}"; do config_var=${var#XACVC_XACC_OPTIONS_} xsh log info "> updating OPTIONS: $config_var ..." - __replace_option_value_by_name__ "$file" "$config_var" "${!var}" + eval "value=\${${var}}" + __replace_option_value_by_name__ "$file" "$config_var" "${value}" done # update OPTIONS: @@ -438,12 +452,16 @@ function config () { declare __prefix__=${1:?} declare __this_vars__=("${@:2}") - declare __prefix_var__ __this_var__ + declare __prefix_var__ __this_var__ __prefix_val__ __this_val__ for __this_var__ in "${__this_vars__[@]}"; do __prefix_var__=${__prefix__}${__this_var__} - if [[ -z ${!__prefix_var__} ]]; then + # `${!name}` indirection is bash-only; `eval` is portable + eval "__prefix_val__=\${${__prefix_var__}}" + if [[ -z ${__prefix_val__} ]]; then + eval "__this_val__=\${${__this_var__}}" + # read into a dynamically-named variable (portable in both shells) # shellcheck disable=SC2229 - read -r "${__prefix_var__}" <<< "${!__this_var__}" + read -r "${__prefix_var__}" <<< "${__this_val__}" fi done } diff --git a/functions/rds/access.sh b/functions/rds/access.sh index 39d0ef3..8287d11 100644 --- a/functions/rds/access.sh +++ b/functions/rds/access.sh @@ -24,7 +24,7 @@ function access () { declare OPTIND OPTARG opt declare -a region_opt - declare instance_id status + declare instance_id __status while getopts r:i:s: opt; do case $opt in @@ -35,7 +35,7 @@ function access () { instance_id=$OPTARG ;; s) - status=$OPTARG + __status=$OPTARG ;; *) return 255 @@ -43,11 +43,11 @@ function access () { esac done - if [[ $status == on ]]; then + if [[ $__status == on ]]; then aws "${region_opt[@]}" \ rds modify-db-instance --db-instance-identifier "${instance_id:?}" \ --publicly-accessible - elif [[ $status == off ]]; then + elif [[ $__status == off ]]; then aws "${region_opt[@]}" \ rds modify-db-instance --db-instance-identifier "${instance_id:?}" \ --no-publicly-accessible diff --git a/functions/s3/test-upload-performance.sh b/functions/s3/test-upload-performance.sh index 57439c3..8dd7ca3 100644 --- a/functions/s3/test-upload-performance.sh +++ b/functions/s3/test-upload-performance.sh @@ -27,6 +27,11 @@ #? @subshell #? function test-upload-performance () { + # zsh doesn't populate FUNCNAME; build it from zsh's funcstack so the + # `${FUNCNAME[0]}` passed to x-trap-return works. No-op under bash. + # shellcheck disable=SC2034,SC2154 + [[ -z ${ZSH_VERSION-} ]] || declare -a FUNCNAME=( "${funcstack[@]}" ) + declare regex=$1 depth=0 if [[ -z $regex ]]; then diff --git a/functions/s3/uri/parser.sh b/functions/s3/uri/parser.sh index 7e4dc13..e88b231 100644 --- a/functions/s3/uri/parser.sh +++ b/functions/s3/uri/parser.sh @@ -59,8 +59,13 @@ #? * https://en.wikipedia.org/wiki/Uniform_Resource_Identifier #? function parser () { - # get the last parameter - declare uri=${!#} + # zsh: make `=~` populate BASH_REMATCH like bash does (the setopt is + # scoped by the ksh emulation applied on import) + # shellcheck disable=SC3044 + [[ -z ${ZSH_VERSION-} ]] || setopt bash_rematch + + # get the last parameter (`${!#}` is bash-only; under zsh it expands as `$#`) + declare uri=${*: -1} #? mybucket.s3-ap-northeast-1.amazsonaws.com #? mybucket.s3.cn-north-1.amazsonaws.com.cn @@ -75,7 +80,11 @@ function parser () { while getopts sahprbk opt; do case $opt in s|a|h|p) - xsh /uri/parser -$opt "$uri" + # capture in a subshell so the nested getopts (in /uri/parser) + # gets its own OPTIND: under zsh's ksh emulation OPTIND is shared + # between functions, so a direct call would reset this loop's + # OPTIND and spin forever + printf '%s\n' "$(xsh /uri/parser -"$opt" "$uri")" ;; r|b) declare scheme host @@ -111,7 +120,8 @@ function parser () { esac ;; k) - xsh /uri/parser -r "$uri" + # subshell-isolate the nested getopts' OPTIND (see note above) + printf '%s\n' "$(xsh /uri/parser -r "$uri")" ;; *) return 255 diff --git a/functions/ses/domain-dkim.sh b/functions/ses/domain-dkim.sh index 545351d..c0a4b5e 100644 --- a/functions/ses/domain-dkim.sh +++ b/functions/ses/domain-dkim.sh @@ -45,17 +45,17 @@ function domain-dkim () { aws "${region_lopt[@]}" ses verify-domain-dkim --domain "$domain" >/dev/null - declare out status + declare out __status out=$(aws "${region_lopt[@]}" ses get-identity-dkim-attributes --identities "$domain") - status=$(xsh /json/parser eval "$out" '{JSON}["DkimAttributes"]["'"$domain"'"]["DkimVerificationStatus"]') + __status=$(xsh /json/parser eval "$out" '{JSON}["DkimAttributes"]["'"$domain"'"]["DkimVerificationStatus"]') declare text="\ * Record Type: CNAME * Name: %s._domainkey.%s * Value: %s.dkim.amazonses.com\n" - if [[ $status == Success ]]; then + if [[ $__status == Success ]]; then printf '[%s]\n' yes | xsh /file/mark else printf '[%s]\n' no | xsh /file/mark @@ -75,7 +75,14 @@ function domain-dkim () { printf "then grab some coffee, it takes time for the DNS to take effect across the internet.\n" - read -r -n 1 -s -p "press any key to continue, CTRL-C to exit." + # read a single keypress silently. bash: `-n 1 -p PROMPT`; zsh: `-k 1` + # with the prompt in the `name?prompt` spec (`-n`/`-p` differ in zsh). + if [[ -n ${ZSH_VERSION-} ]]; then + # shellcheck disable=SC2229,SC2034 + read -r -s -k 1 "REPLY?press any key to continue, CTRL-C to exit." + else + read -r -n 1 -s -p "press any key to continue, CTRL-C to exit." + fi printf '\n\n' @domain-dkim "${region_sopt[@]}" "$domain" fi diff --git a/functions/ses/domain-identity.sh b/functions/ses/domain-identity.sh index 6a6825e..3cd1fbb 100644 --- a/functions/ses/domain-identity.sh +++ b/functions/ses/domain-identity.sh @@ -45,17 +45,17 @@ function domain-identity () { aws "${region_opt[@]}" ses verify-domain-identity --domain "$domain" >/dev/null - declare out status + declare out __status out=$(aws "${region_opt[@]}" ses get-identity-verification-attributes --identities "$domain") - status=$(xsh /json/parser eval "$out" '{JSON}["VerificationAttributes"]["'$domain'"]["VerificationStatus"]') + __status=$(xsh /json/parser eval "$out" '{JSON}["VerificationAttributes"]["'$domain'"]["VerificationStatus"]') declare text="\ * Record Type: TXT (Text) * TXT Name*: _amazonses.%s * TXT Value: %s\n" - if [[ $status == Success ]]; then + if [[ $__status == Success ]]; then printf '[%s]\n' yes | xsh /file/mark else printf '[%s]\n' no | xsh /file/mark diff --git a/functions/ses/sandbox/move.sh b/functions/ses/sandbox/move.sh index cb18e7f..9d8dcae 100644 --- a/functions/ses/sandbox/move.sh +++ b/functions/ses/sandbox/move.sh @@ -58,16 +58,16 @@ function move () { printf "[yes]\n" | xsh /file/mark printf "checking the support case status ... " - declare status - status=$(aws --region us-east-1 \ + declare __status + __status=$(aws --region us-east-1 \ --query '[].status' \ support describe-cases \ --case-id-list "$case_id" \ --include-resolved-cases) - printf "[%s]\n" "$status" | xsh /file/mark + printf "[%s]\n" "$__status" | xsh /file/mark - if [[ $status == Resolved ]]; then + if [[ $__status == Resolved ]]; then printf 'continue to recheck the sandbox status.\n' else printf 'please wait for the support case to be resolved, then continue.\n' @@ -121,7 +121,14 @@ function move () { printf '%s\n' "${msg[@]}" fi - read -r -n 1 -s -p "press any key to continue, CTRL-C to exit." + # read a single keypress silently. bash: `-n 1 -p PROMPT`; zsh: `-k 1` + # with the prompt in the `name?prompt` spec (`-n`/`-p` differ in zsh). + if [[ -n ${ZSH_VERSION-} ]]; then + # shellcheck disable=SC2229,SC2034 + read -r -s -k 1 "REPLY?press any key to continue, CTRL-C to exit." + else + read -r -n 1 -s -p "press any key to continue, CTRL-C to exit." + fi printf '\n\n' @move "${region_opt[@]}" } diff --git a/functions/spt/create.sh b/functions/spt/create.sh index d0f0078..da9b241 100644 --- a/functions/spt/create.sh +++ b/functions/spt/create.sh @@ -72,6 +72,6 @@ function create () { support create-case \ --subject "${subject:?}" \ --communication-body "${body:?}" \ - --output text - "${options[@]}" \ + --output text \ + "${options[@]}" } diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..7db79e8 --- /dev/null +++ b/test.sh @@ -0,0 +1,171 @@ +#!/bin/bash + +# Make the `xsh` function available when this script is run as a child process. +# Under bash, xsh and the imported utilities are exported functions, so a child +# `bash test.sh` inherits them and this is a no-op. zsh cannot export functions, +# so a child `zsh test.sh` would otherwise only see the `bin/xsh` shim (which +# runs bash); sourcing ~/.xshrc here defines xsh as a real zsh function so the +# utilities execute under zsh's ksh emulation — the point of testing under zsh. +if ! type xsh 2>/dev/null | grep -q 'function'; then + # shellcheck source=/dev/null + . ~/.xshrc +fi + +# NOTE: this suite deliberately does NOT use `set -e`. Many aws utilities end +# with a getopts/case loop and so return that loop's final (non-zero) status +# even on success — harmless in normal use, but under zsh's stricter ERR_EXIT a +# `$(util ...)` in a command substitution would abort the script. Assertions +# therefore compare captured output and tally failures explicitly. + +__dir=$(cd "$(dirname "$0")" && pwd) +__fails=0 + +assert_eq () { # + if [ "$2" = "$3" ]; then + printf 'ok - %s\n' "$1" + else + printf 'FAIL - %s: expected [%s], got [%s]\n' "$1" "$2" "$3" >&2 + __fails=$((__fails + 1)) + fi +} + +assert_rc_nonzero () { # [args...] — util must exit non-zero + if "${@:2}" >/dev/null 2>&1; then + printf 'FAIL - %s: expected non-zero exit\n' "$1" >&2 + __fails=$((__fails + 1)) + else + printf 'ok - %s\n' "$1" + fi +} + +xsh log info 'xsh list aws/' +xsh list 'aws/*' >/dev/null + +# ============================================================ +# import-smoke: every function utility must source cleanly. +# The broadest portability check — under zsh each utility is sourced with the +# injected `emulate -L ksh`, so a syntax/runtime-substitution problem surfaces +# here. (scripts/ utilities are always executed via `bash` regardless of the +# caller's shell, so they need no zsh handling and are not smoked here; importing +# them would also need a writable /usr/local/bin.) +# ============================================================ +xsh log info "import-smoke: all aws function utilities" +while read -r __lpue; do + [ -z "$__lpue" ] && continue + if xsh import "$__lpue" >/dev/null 2>&1; then + : + else + printf 'FAIL - import %s\n' "$__lpue" >&2 + __fails=$((__fails + 1)) + fi +done < <(xsh list 'aws/*' | awk '$1 == "[functions]" {print $2}') +printf 'ok - import-smoke (all function utilities sourced)\n' + +# ============================================================ +# aws/s3/uri/parser — pure URI parsing (uses BASH_REMATCH; zsh needs +# `setopt bash_rematch`, applied in the util) +# ============================================================ +xsh log info "aws/s3/uri/parser" +assert_eq "s3/uri/parser -s s3://" s3 "$(xsh aws/s3/uri/parser -s s3://mybucket/foo/bar.zip 2>/dev/null)" +assert_eq "s3/uri/parser -b s3://" mybucket "$(xsh aws/s3/uri/parser -b s3://mybucket/foo/bar.zip 2>/dev/null)" +assert_eq "s3/uri/parser -k s3://" foo/bar.zip "$(xsh aws/s3/uri/parser -k s3://mybucket/foo/bar.zip 2>/dev/null)" +assert_eq "s3/uri/parser -b https" mybucket "$(xsh aws/s3/uri/parser -b https://mybucket.s3-ap-northeast-1.amazonaws.com/foo/bar.zip 2>/dev/null)" +assert_eq "s3/uri/parser -r https" ap-northeast-1 "$(xsh aws/s3/uri/parser -r https://mybucket.s3-ap-northeast-1.amazonaws.com/foo/bar.zip 2>/dev/null)" +# China partition (host ends with .amazonaws.com.cn) +assert_eq "s3/uri/parser -r https (.cn)" cn-north-1 "$(xsh aws/s3/uri/parser -r https://mybucket.s3.cn-north-1.amazonaws.com.cn/k 2>/dev/null)" + +# ============================================================ +# aws/s3/uri/translate — scheme translation between https and s3 +# ============================================================ +xsh log info "aws/s3/uri/translate" +assert_eq "s3/uri/translate https->s3" s3://mybucket/foo/bar.zip \ + "$(xsh aws/s3/uri/translate -s s3 https://mybucket.s3-ap-northeast-1.amazonaws.com/foo/bar.zip 2>/dev/null)" +assert_eq "s3/uri/translate s3->s3 (no-op)" s3://mybucket/foo/bar.zip \ + "$(xsh aws/s3/uri/translate -s s3 s3://mybucket/foo/bar.zip 2>/dev/null)" + +# ============================================================ +# aws/cfg/get — reads ~/.aws/{config,credentials} via /ini/parser and emits CSV +# (exercises the ${!var} name-indirection that was ported to portable `eval`). +# Uses a throwaway HOME so a real ~/.aws is never read or touched. +# ============================================================ +xsh log info "aws/cfg/get (fixture config/credentials)" +__cfg_home=$(mktemp -d "${TMPDIR:-/tmp}/xsh-aws-cfg-test.XXXXXXXX") +mkdir -p "$__cfg_home/.aws" +cat > "$__cfg_home/.aws/config" <<'CFG' +[default] +region = us-east-1 +output = json + +[profile dev] +region = eu-west-1 +output = text +CFG +cat > "$__cfg_home/.aws/credentials" <<'CRED' +[default] +aws_access_key_id = AKIADEFAULT +aws_secret_access_key = SECRETDEFAULT + +[dev] +aws_access_key_id = AKIADEV +aws_secret_access_key = SECRETDEV +CRED + +__saved_home=$HOME +export HOME=$__cfg_home # XSH_HOME is absolute/exported, so xsh stays anchored +__cfg_default=$(xsh aws/cfg/get default 2>/dev/null) +__cfg_all=$(xsh aws/cfg/get 2>/dev/null) +export HOME=$__saved_home +rm -rf "$__cfg_home" + +assert_eq "cfg/get default" "default,us-east-1,AKIADEFAULT,SECRETDEFAULT,json" "$__cfg_default" +assert_eq "cfg/get all (line count)" 2 "$(printf '%s\n' "$__cfg_all" | grep -c ,)" +case $__cfg_all in + *"dev,eu-west-1,AKIADEV,SECRETDEV,text"*) + printf 'ok - cfg/get includes dev profile\n' ;; + *) + printf 'FAIL - cfg/get missing dev profile, got [%s]\n' "$__cfg_all" >&2 + __fails=$((__fails + 1)) ;; +esac + +# ============================================================ +# cfn STACK_STATUS classification table — the sparse-array build was rewritten +# to portable (code, name) pairs. Verify the derived value-sets are correct. +# Sourced in a subshell (the init file only assigns variables). +# ============================================================ +xsh log info "cfn STACK_STATUS classification" +__stack_status_ok=$( + # __init__.sh builds the table assuming bash-style 0-indexed arrays; xsh + # supplies that via `emulate -L ksh` on import, so replicate it here when + # sourcing the file directly under zsh. (No-op under bash.) + [ -n "${ZSH_VERSION:-}" ] && emulate -L ksh + # shellcheck source=/dev/null + . "$__dir/functions/cfn/__init__.sh" + rc=ok + stable=" ${XSH_AWS_CFN__STACK_STATUS_STABLE[*]} " + unstable=" ${XSH_AWS_CFN__STACK_STATUS_UNSTABLE[*]} " + complete=" ${XSH_AWS_CFN__STACK_STATUS_COMPLETE[*]} " + # STABLE holds steady states; UNSTABLE the *_IN_PROGRESS states + [[ $stable == *" CREATE_COMPLETE "* ]] || rc=bad-stable + [[ $stable != *" CREATE_IN_PROGRESS "* ]] || rc=stable-has-progress + [[ $unstable == *" CREATE_IN_PROGRESS "* ]] || rc=bad-unstable + [[ $complete == *" UPDATE_COMPLETE "* ]] || rc=bad-complete + printf '%s' "$rc" +) +assert_eq "cfn STACK_STATUS classification" ok "$__stack_status_ok" + +# ============================================================ +# argument validation — a utility that fails fast (early return, before any +# getopts loop) so its exit code is meaningful. +# ============================================================ +xsh log info "aws/cfg/set (no profile = error)" +assert_rc_nonzero "cfg/set with empty profile errors" xsh aws/cfg/set '' + +# ============================================================ +printf '\n' +if [ "$__fails" -eq 0 ]; then + xsh log info "aws tests: all passed" + exit 0 +else + xsh log error "aws tests: ${__fails} failure(s)" + exit 1 +fi From 559be59fef8704183f4d5425f55c1e62fbf2c7d4 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 16:32:48 +0800 Subject: [PATCH 2/3] test: skip cfg/get when xsh-lib/core /ini/parser is unusable cfg/get parses ~/.aws/config via xsh-lib/core's /ini/parser. Older core releases ship an ini/parser.awk that aborts under gawk (most Linux); fixed in core, but this suite loads core's latest *stable tag*, which may predate the fix. Probe /ini/parser on the fixture and SKIP (not fail) the cfg/get assertions when it's unusable, so the suite doesn't go red on a dependency-version mismatch. Once a core with the gawk-safe parser is released, the assertions run automatically. Co-Authored-By: Claude Opus 4.8 (1M context) --- test.sh | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/test.sh b/test.sh index 7db79e8..ff7b361 100755 --- a/test.sh +++ b/test.sh @@ -110,23 +110,33 @@ aws_access_key_id = AKIADEV aws_secret_access_key = SECRETDEV CRED -__saved_home=$HOME -export HOME=$__cfg_home # XSH_HOME is absolute/exported, so xsh stays anchored -__cfg_default=$(xsh aws/cfg/get default 2>/dev/null) -__cfg_all=$(xsh aws/cfg/get 2>/dev/null) -export HOME=$__saved_home +# cfg/get delegates the parsing to xsh-lib/core's /ini/parser. Older core +# releases ship an ini/parser.awk that aborts under gawk (most Linux) with +# "attempt to use scalar as an array" — fixed in core, but this test loads +# core's latest *stable tag*, which may predate that fix. Probe it and skip +# (rather than fail) the cfg/get assertions when /ini/parser is unusable, so +# this suite doesn't go red on a dependency-version mismatch. +if xsh /ini/parser -p __probe_ "$__cfg_home/.aws/config" >/dev/null 2>&1; then + __saved_home=$HOME + export HOME=$__cfg_home # XSH_HOME is absolute/exported, so xsh stays anchored + __cfg_default=$(xsh aws/cfg/get default 2>/dev/null) + __cfg_all=$(xsh aws/cfg/get 2>/dev/null) + export HOME=$__saved_home + + assert_eq "cfg/get default" "default,us-east-1,AKIADEFAULT,SECRETDEFAULT,json" "$__cfg_default" + assert_eq "cfg/get all (line count)" 2 "$(printf '%s\n' "$__cfg_all" | grep -c ,)" + case $__cfg_all in + *"dev,eu-west-1,AKIADEV,SECRETDEV,text"*) + printf 'ok - cfg/get includes dev profile\n' ;; + *) + printf 'FAIL - cfg/get missing dev profile, got [%s]\n' "$__cfg_all" >&2 + __fails=$((__fails + 1)) ;; + esac +else + printf 'SKIP - cfg/get: xsh-lib/core /ini/parser unusable here (needs the gawk-safe ini/parser.awk fix)\n' +fi rm -rf "$__cfg_home" -assert_eq "cfg/get default" "default,us-east-1,AKIADEFAULT,SECRETDEFAULT,json" "$__cfg_default" -assert_eq "cfg/get all (line count)" 2 "$(printf '%s\n' "$__cfg_all" | grep -c ,)" -case $__cfg_all in - *"dev,eu-west-1,AKIADEV,SECRETDEV,text"*) - printf 'ok - cfg/get includes dev profile\n' ;; - *) - printf 'FAIL - cfg/get missing dev profile, got [%s]\n' "$__cfg_all" >&2 - __fails=$((__fails + 1)) ;; -esac - # ============================================================ # cfn STACK_STATUS classification table — the sparse-array build was rewritten # to portable (code, name) pairs. Verify the derived value-sets are correct. From 3fb9022aaa9c56a0c2939f34b048d9f895108c69 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 18:16:31 +0800 Subject: [PATCH 3/3] ci: make zsh jobs gating now that xsh 0.7.0 + core 0.6.0 are released Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8a77fe..d2bdcd4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,11 +17,6 @@ jobs: shell: [bash, zsh] runs-on: ${{ matrix.os }} name: ${{ matrix.os }} / ${{ matrix.shell }} - # The zsh jobs install xsh from alexzhangs/xsh master and xsh-lib/core's - # latest stable tag, neither of which yet carries zsh support — so they - # stay red until those releases land. Keep them non-blocking until then. - # TODO: remove this once a zsh-supporting xsh + xsh-lib/core are released. - continue-on-error: ${{ matrix.shell == 'zsh' }} steps: - name: Install zsh (Linux)