diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index b61de763..1f89f2ea 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -7,11 +7,28 @@ on: - main - renovate/* +# The shellcheck version is pinned so CI and a developer's machine agree. +# The runner's own apt shellcheck drifts (it was 0.9.0 while a current Homebrew +# is 0.11.0), and the two disagree on findings — 0.9 flags SC2015/SC2002 that +# 0.11 does not — so an unpinned job passes or fails by accident. Match it +# locally with the same release, or `brew install shellcheck@0.10` where pinned. +env: + SHELLCHECK_VERSION: "0.10.0" + jobs: shellcheck: name: ShellCheck runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install the pinned ShellCheck + run: | + set -euo pipefail + url="https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + curl -fsSL "$url" | tar -xJ + sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck + shellcheck --version + - name: Run ShellCheck run: find . -type f -name "*.sh" -not -path "./.git/*" -exec shellcheck {} + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 464557ca..4521c431 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,58 @@ jobs: GD_TEST_MIN_COMPOSE: ${{ runner.temp }}/min-compose/docker-compose run: node --test --test-timeout=120000 tests/*.test.mjs + # macOS is a supported host for local development (`install.sh --local`), so + # the shell helpers run on its BSD userland and bash 3.2, where a GNU-only + # construct that passes on Linux breaks. This job exercises them there. + # + # GitHub's macOS runners have no Docker daemon, so the daemon-backed tests + # (mode matrix, Caddy, ingress, the installer end to end) skip themselves + # through `dockerAvailable()`. The Docker *CLI* is installed anyway — no + # daemon — so the tests that only parse configuration (`docker compose + # config`) or probe for an unreachable daemon still run and are meaningful. + # Production ingress stays a Linux concern and is not run here. + helpers-macos: + name: Helpers on macOS + runs-on: macos-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: "22" + + - name: Install the Docker CLI and Compose plugin (no daemon) + env: + # A current Compose is enough: it is only used to *parse* configuration + # (`docker compose config`), never to reach a daemon. + COMPOSE_PLUGIN_VERSION: "2.29.7" + run: | + set -euo pipefail + # The runner has no Docker at all. `brew install docker` gives the CLI + # (client only, no daemon); the Homebrew compose formula does not wire + # itself up as a `docker compose` subcommand, so the plugin is dropped + # into cli-plugins directly instead. + brew install docker + mkdir -p ~/.docker/cli-plugins + curl -fsSL \ + "https://github.com/docker/compose/releases/download/v${COMPOSE_PLUGIN_VERSION}/docker-compose-darwin-aarch64" \ + -o ~/.docker/cli-plugins/docker-compose + chmod +x ~/.docker/cli-plugins/docker-compose + docker --version + docker compose version + jq --version + + - name: Run the helper tests under bash 3.2 + env: + # macOS's system bash is 3.2, which the helpers target; the runner's + # own bash on PATH is newer and would hide a 3.2 incompatibility, so + # the suite is pointed at /bin/bash explicitly. + GD_TEST_BASH: /bin/bash + run: | + set -eu + /bin/bash --version | head -1 + node --test --test-timeout=120000 tests/*.test.mjs + ingress: name: Ingress smoke tests runs-on: ubuntu-latest diff --git a/scripts/lib/env.sh b/scripts/lib/env.sh index c938d4e9..33c85d50 100644 --- a/scripts/lib/env.sh +++ b/scripts/lib/env.sh @@ -159,8 +159,15 @@ env_get() { printf 'error: %s spans several lines; edit it by hand\n' "$2" >&2 return 1 ;; - # Single quoted: literal, and a backslash before a quote is the only escape. - s) printf '%s\n' "${found_body//\\\'/\'}" ;; + # Single quoted: literal, and a backslash before a quote is the only + # escape. The substitution is done in an unquoted assignment rather than + # inside the printf's double quotes: bash 3.2 (macOS's system bash) and + # bash 4+ parse the backslashes in a double-quoted `${v//\\\'/\'}` + # pattern differently, and only the unquoted form agrees on both. + s) + local unescaped=${found_body//\\\'/\'} + printf '%s\n' "$unescaped" + ;; *) _gd_env_unescape "$found_body" printf '\n' diff --git a/tests/helpers.mjs b/tests/helpers.mjs index d2f26a3a..768207ad 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -22,9 +22,14 @@ const LIBS = ['fs', 'env', 'compose', 'config', 'caddy', 'meta', 'preflight', 'i * Returns { stdout, stderr, status }. Throws only if the shell itself cannot * be started. */ +// The helpers target bash 3.2 (macOS's system bash). GD_TEST_BASH points the +// suite at a specific interpreter — the macOS CI job sets it to /bin/bash so a +// bash-4+-ism that a newer Homebrew bash would tolerate is caught. +const BASH = process.env.GD_TEST_BASH || 'bash'; + export function sh(script, { cwd = REPO_DIR, env = {}, input } = {}) { const preamble = LIBS.map((l) => `. "${REPO_DIR}/scripts/lib/${l}.sh"`).join('\n'); - const result = spawnSync('bash', ['-c', `${preamble}\n${script}`], { + const result = spawnSync(BASH, ['-c', `${preamble}\n${script}`], { cwd, input, env: { ...process.env, ...env },