From a41431fdd497caa8d3c325f3aca7668330f40991 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 14:12:40 -0700 Subject: [PATCH 1/5] ci: harden installer canary and add pre-merge Bash 3.2 gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canary (installer-smoke.yml): - Trigger on scripts/install.ps1 pushes too — the raw URL serves main, so merge == deploy for the PowerShell installer as well. - New Windows job running the documented irm | iex shape under both Windows PowerShell 5.1 and pwsh 7 (matrix), against scratch USERPROFILE/BASECAMP_BIN_DIR under RUNNER_TEMP. - New notify job: on any leg's failure, file or update an "Installer canary failure" issue (exact-title dedupe, best-effort gh calls, ::error:: annotation). Skipped entirely on green runs. Pre-merge (test.yml): new installer-bash32 job on macos-latest — plain steps, not bats (bats 1.11 execs `env bash` at every layer, so the interpreter is not pinnable through it). Asserts /bin/bash is exactly 3.2, syntax-checks install.sh, and runs it piped with curl off PATH, asserting the guard's clean failure message and the absence of unbound-variable errors (the #558 regression class). Path-filtered to installer files plus test.yml itself so the job proves itself on the PR that introduces or edits it; skipped filters still report success, so the job is safe to mark required. Refs #558 #568 #569 --- .github/workflows/installer-smoke.yml | 82 ++++++++++++++++++++++++++- .github/workflows/test.yml | 64 +++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index e799b42e8..f0a661d71 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -13,12 +13,14 @@ on: branches: [main] paths: - scripts/install.sh + - scripts/install.ps1 permissions: {} # The post-install skill step reaches for the OS keyring; on a headless macOS # runner the `security` tool blocks forever on the locked keychain (caught by -# this canary's first run). Same escape hatch the Test workflow uses. +# this canary's first run). Also keeps the Windows legs out of Credential +# Manager. Same escape hatch the Test workflow uses. env: BASECAMP_NO_KEYRING: "1" @@ -77,3 +79,81 @@ jobs: curl -fsSL "https://basecamp.com/install-cli?sha=${GITHUB_SHA}" | /bin/bash "$BASECAMP_BIN_DIR/basecamp" version + + windows: + name: Windows (${{ matrix.shell }}) + runs-on: windows-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # powershell (5.1) is the stock interpreter the README's `irm | iex` + # shape targets and exercises install.ps1's 5.1-specific branches; + # pwsh covers PowerShell 7. + shell: [powershell, pwsh] + steps: + - name: Install from the published endpoint + # GitHub Actions does not evaluate expressions in steps[*].shell, so a + # pwsh launcher step invokes the matrix interpreter explicitly. The + # documented `irm | iex` shape — fetch and execution both — runs + # entirely inside the target interpreter; the launcher only sets env + # (inherited by the child) and asserts afterwards. + shell: pwsh + env: + SMOKE_SHELL: ${{ matrix.shell }} + run: | + # Scratch USERPROFILE + explicit BASECAMP_BIN_DIR contain the + # installer's and the child basecamp.exe's writes (Go os.UserHomeDir + # reads USERPROFILE). Ensure-UserPath's HKCU PATH write is + # unavoidable but disposable on ephemeral runners. + $smokeRoot = Join-Path $env:RUNNER_TEMP 'installer-smoke' + New-Item -ItemType Directory -Force -Path (Join-Path $smokeRoot 'home') | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $smokeRoot 'bin') | Out-Null + $env:USERPROFILE = Join-Path $smokeRoot 'home' + $env:BASECAMP_BIN_DIR = Join-Path $smokeRoot 'bin' + $env:BASECAMP_SKIP_SETUP = '1' + $env:BASECAMP_SETUP_AGENT = 'none' + + # Documented Quick Start shape, cache-busted: raw.githubusercontent.com + # varies its cache key on unknown query params, so ?sha= keeps each + # commit's request cache-distinct (no basecamp.com endpoint exists + # for the PowerShell installer). Single-quoted so the inner + # interpreter expands $env:GITHUB_SHA itself. + & $env:SMOKE_SHELL -NoProfile -Command 'irm "https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1?sha=$env:GITHUB_SHA" | iex' + if ($LASTEXITCODE -ne 0) { throw "installer exited $LASTEXITCODE under $env:SMOKE_SHELL" } + + & "$env:BASECAMP_BIN_DIR\basecamp.exe" version + if ($LASTEXITCODE -ne 0) { throw "basecamp version exited $LASTEXITCODE" } + + notify: + name: Notify on failure + needs: [linux, macos, windows] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: File or update canary failure issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EVENT_NAME: ${{ github.event_name }} + LINUX_RESULT: ${{ needs.linux.result }} + MACOS_RESULT: ${{ needs.macos.result }} + WINDOWS_RESULT: ${{ needs.windows.result }} + run: | + TITLE="Installer canary failure" + BODY="The installer canary failed (event: ${EVENT_NAME}). Legs: linux=${LINUX_RESULT}, macos=${MACOS_RESULT}, windows=${WINDOWS_RESULT}. See ${RUN_URL} for details." + + # Check for existing open issue before creating a new one + existing=$(gh issue list --state open --search "in:title $TITLE" --json number,title --jq '[.[] | select(.title == "'"$TITLE"'")][0].number // empty' 2>/dev/null || true) + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$BODY" || true + else + gh issue create --title "$TITLE" --body "$BODY" || true + fi + + # Always emit annotation so the failure is visible in the workflow summary + echo "::error::Installer canary failed. See ${RUN_URL}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08c691d8e..cf0834f60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -246,6 +246,70 @@ jobs: fi make skill-eval + installer-bash32: + name: Installer (bash 3.2) + runs-on: macos-latest + timeout-minutes: 10 + permissions: + contents: read + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check for installer changes + id: filter + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + with: + # test.yml is in its own filter so the job proves itself on the PR + # that introduces or edits it. + filters: | + installer: + - 'scripts/install.sh' + - 'e2e/installer.bats' + - '.github/workflows/test.yml' + + - name: Assert /bin/bash is Bash 3.2 + if: steps.filter.outputs.installer == 'true' + run: | + # Same guard as the installer-smoke canary: honest 3.2 coverage + # instead of silently degrading if a future runner image changes + # /bin/bash. The e2e bats suite cannot provide this — bats 1.11.0 + # execs `env bash` at every layer, so the interpreter is not + # pinnable through it. + /bin/bash -c '[[ "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" == "3.2" ]]' \ + || { echo "/bin/bash is not Bash 3.2:" >&2; /bin/bash --version >&2; exit 1; } + + - name: Syntax-check installer under Bash 3.2 + if: steps.filter.outputs.installer == 'true' + run: /bin/bash -n scripts/install.sh + + - name: Piped execution under Bash 3.2 + if: steps.filter.outputs.installer == 'true' + run: | + # PATH=/nonexistent forces the curl guard: the run must fail with + # the guard's own message, never with an unbound-variable error + # (the #558 regression class). A bare out=$(...) assignment of the + # failing pipeline would exit this step immediately under the + # runner's `bash -e -o pipefail`, so capture via if-else. + status=0 + if out=$(cat scripts/install.sh | PATH=/nonexistent /bin/bash 2>&1); then + status=0 + else + status=$? + fi + printf '%s\n' "$out" + if [ "$status" -eq 0 ]; then + echo "expected the curl guard to fail without curl on PATH" >&2 + exit 1 + fi + printf '%s\n' "$out" | grep -q 'curl is required but not installed' + if printf '%s\n' "$out" | grep -q 'unbound variable'; then + echo "installer emitted an unbound variable error under Bash 3.2" >&2 + exit 1 + fi + benchmarks: name: Benchmarks runs-on: ubuntu-latest From 37cf5f79be120c7fd410dd96111901ce351701da Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 14:14:25 -0700 Subject: [PATCH 2/5] TEMP: notify drill (revert me) --- .github/workflows/installer-smoke.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index f0a661d71..8561e997f 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -56,6 +56,9 @@ jobs: # Assert the deterministic installed path, not ambient PATH. "$BASECAMP_BIN_DIR/basecamp" version + # TEMPORARY notify drill — reverted before review. + exit 1 + macos: name: macOS (stock bash 3.2) runs-on: macos-latest From dc68c3db5a4551fcc20a52e6a441c1a3b239e1e0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 14:16:26 -0700 Subject: [PATCH 3/5] Revert "TEMP: notify drill (revert me)" This reverts commit 37cf5f79be120c7fd410dd96111901ce351701da. --- .github/workflows/installer-smoke.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index 8561e997f..f0a661d71 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -56,9 +56,6 @@ jobs: # Assert the deterministic installed path, not ambient PATH. "$BASECAMP_BIN_DIR/basecamp" version - # TEMPORARY notify drill — reverted before review. - exit 1 - macos: name: macOS (stock bash 3.2) runs-on: macos-latest From 1d390d7d951224f6e6dcd12ff6a42393c4186527 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 14:50:56 -0700 Subject: [PATCH 4/5] ci: run a full offline install under Bash 3.2, not just the curl guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The piped-execution step stops at the curl prerequisite, and `bash -n` only checks grammar — neither catches Bash-4-only constructs in the download, checksum, extract, or post-install code. Add a step that runs one complete install path under real /bin/bash 3.2 using a fixture curl serving a local release (stub binary, real sha256 checksums) with PATH restricted to fixture + system dirs, then asserts the installed stub runs. Review feedback on #575. --- .github/workflows/test.yml | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf0834f60..eec9552a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -310,6 +310,81 @@ jobs: exit 1 fi + - name: Offline full install under Bash 3.2 + if: steps.filter.outputs.installer == 'true' + run: | + # The curl-guard step above never gets past the prerequisite check, + # and `bash -n` only checks grammar — neither catches Bash-4-only + # constructs (declare -A, mapfile, 4.x expansions) in the download, + # checksum, extract, or post-install code. Run one complete install + # under real 3.2: a fixture curl serves a local release from disk, + # and a pinned BASECAMP_VERSION skips version resolution. PATH is + # restricted to fixture + system dirs so Homebrew tools (bash 4+, + # cosign) cannot leak in. + fixture_root=$(mktemp -d) + mkdir -p "$fixture_root/bin" "$fixture_root/release" "$fixture_root/home" "$fixture_root/install" + + cat > "$fixture_root/release/basecamp" <<'STUB' + #!/bin/bash + if [ "$1" = "--version" ]; then echo "basecamp version 9.9.9"; fi + exit 0 + STUB + chmod +x "$fixture_root/release/basecamp" + for arch in arm64 amd64; do + tar -czf "$fixture_root/release/basecamp_9.9.9_darwin_${arch}.tar.gz" -C "$fixture_root/release" basecamp + done + (cd "$fixture_root/release" && shasum -a 256 ./*.tar.gz | sed 's|\./||' > checksums.txt) + + cat > "$fixture_root/bin/curl" <<'STUB' + #!/bin/bash + # Fixture curl: serves the local release dir. Handles the two shapes + # install.sh uses: `--version` and `[-flags] -o `. + out=""; url="" + while [ $# -gt 0 ]; do + case "$1" in + --version) echo "curl 8.0.0 (fixture)"; exit 0 ;; + -o) out="$2"; shift ;; + http://*|https://*) url="$1" ;; + esac + shift + done + name="${url##*/}" + if [ -n "$out" ] && [ -f "$FIXTURE_RELEASE_DIR/$name" ]; then + cp "$FIXTURE_RELEASE_DIR/$name" "$out" + exit 0 + fi + echo "fixture curl: no fixture for: $url" >&2 + exit 22 + STUB + chmod +x "$fixture_root/bin/curl" + + status=0 + if out=$(cat scripts/install.sh | \ + HOME="$fixture_root/home" \ + FIXTURE_RELEASE_DIR="$fixture_root/release" \ + BASECAMP_BIN_DIR="$fixture_root/install" \ + BASECAMP_VERSION=9.9.9 \ + BASECAMP_SKIP_SETUP=1 \ + BASECAMP_SETUP_AGENT=none \ + PATH="$fixture_root/bin:/usr/bin:/bin" \ + /bin/bash 2>&1); then + status=0 + else + status=$? + fi + printf '%s\n' "$out" + if [ "$status" -ne 0 ]; then + echo "offline install failed under Bash 3.2 (exit $status)" >&2 + exit 1 + fi + printf '%s\n' "$out" | grep -q 'Installed basecamp to' + printf '%s\n' "$out" | grep -q 'version 9.9.9 installed' + if printf '%s\n' "$out" | grep -q 'unbound variable'; then + echo "installer emitted an unbound variable error under Bash 3.2" >&2 + exit 1 + fi + "$fixture_root/install/basecamp" --version + benchmarks: name: Benchmarks runs-on: ubuntu-latest From 2421186d5798d313abfd2faf40d621c83a95ef00 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 15:06:52 -0700 Subject: [PATCH 5/5] ci: grant the 3.2 gate pull-requests read for paths-filter dorny/paths-filter enumerates changed PR files via the REST API and documents a pull-requests: read requirement; the job-level permissions block zeroes everything unlisted. Review feedback on #575. --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eec9552a0..e31b21da6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -252,6 +252,7 @@ jobs: timeout-minutes: 10 permissions: contents: read + pull-requests: read # dorny/paths-filter enumerates PR files via the REST API if: github.event_name == 'pull_request' steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0