From d19a73eef8940458b64928511a1adbe2b23aec44 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 3 Sep 2026 13:55:46 -0400 Subject: [PATCH 1/3] ci: macOS helper matrix + pinned shellcheck; fix env.sh single-quote decode on bash 3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS is a supported local-dev host (`install.sh --local`), so the shell helpers run on its BSD userland and bash 3.2 — where a construct that passes on Linux or a newer bash can break. Nothing exercised that until now. - test.yml: add a macos-latest job running the suite under /bin/bash (3.2) with the Docker CLI but no daemon. Daemon-backed tests (mode matrix, Caddy, ingress, installer e2e) skip themselves via dockerAvailable(); the config-parsing and unreachable-daemon tests still run and are meaningful. Production ingress stays a Linux concern. - helpers.mjs: honor GD_TEST_BASH so the suite can be pointed at a specific interpreter; the macOS job sets it to /bin/bash. - shellcheck.yml: pin shellcheck to 0.10.0 (install the release) so CI and a developer's machine agree, instead of the runner's drifting apt version that disagreed with local on SC2015/SC2002. The new job immediately caught a real bash-3.2 bug it now guards against: - env.sh: the single-quote `\'` decode ran inside the printf's double quotes, where bash 3.2 and 4+ parse the pattern backslashes differently, so a single-quoted value read back wrong on macOS. Do the substitution in an unquoted assignment, which both agree on. Verified end to end under /bin/bash 3.2 (109 pass) and bash 5 (234 pass). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/shellcheck.yml | 17 ++++++++++++++ .github/workflows/test.yml | 39 ++++++++++++++++++++++++++++++++ scripts/lib/env.sh | 11 +++++++-- tests/helpers.mjs | 7 +++++- 4 files changed, 71 insertions(+), 3 deletions(-) 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..56b44cf5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,45 @@ 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 host tools (Docker CLI only, no daemon) + run: | + set -eu + brew install jq docker docker-compose + jq --version + docker --version + docker compose 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 }, From 2f781c4339b699894e90a45d0b16b32a37487dc2 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 3 Sep 2026 14:01:07 -0400 Subject: [PATCH 2/3] ci(macos): install the compose plugin directly; the runner already has the docker CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS runner ships the Docker CLI (29.6.2) but no `docker compose` plugin, and `brew install docker-compose` does not register it as a `docker compose` subcommand, so the setup step failed on `docker compose version`. Install the plugin binary into ~/.docker/cli-plugins directly (pinned, darwin-aarch64), matching how the Linux job pins its Compose. No daemon involved — it is only used to parse configuration. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56b44cf5..95f4fd17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,13 +64,24 @@ jobs: with: node-version: "22" - - name: Install host tools (Docker CLI only, no daemon) + - name: Install the Docker Compose plugin (CLI only, 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 -eu - brew install jq docker docker-compose - jq --version + set -euo pipefail + # The runner already ships the Docker CLI; only the compose plugin is + # missing, and `brew install docker-compose` does not wire it up as a + # `docker compose` subcommand. Install it as a CLI plugin directly. docker --version + 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 compose version + jq --version - name: Run the helper tests under bash 3.2 env: From b5350c6b3c8c9fb8042c46b5f66dd6456547aeff Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 3 Sep 2026 14:03:24 -0400 Subject: [PATCH 3/3] =?UTF-8?q?ci(macos):=20brew=20install=20the=20Docker?= =?UTF-8?q?=20CLI=20too=20=E2=80=94=20the=20runner=20ships=20no=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior "docker 29.6.2" came from the earlier `brew install docker`, not from the runner: macOS runners have no Docker at all, so dropping the install left `docker: command not found`. Restore `brew install docker` for the client (no daemon) and keep the direct compose-plugin download, since Homebrew's compose formula is not registered as a `docker compose` subcommand. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95f4fd17..4521c431 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,22 +64,24 @@ jobs: with: node-version: "22" - - name: Install the Docker Compose plugin (CLI only, no daemon) + - 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 already ships the Docker CLI; only the compose plugin is - # missing, and `brew install docker-compose` does not wire it up as a - # `docker compose` subcommand. Install it as a CLI plugin directly. - docker --version + # 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