From 66dd58e777eb0967bdc21a7ae604fd6ca8022712 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 07:51:51 -0700 Subject: [PATCH 1/4] Add Docker install/upgrade support on Linux and Windows, with a version floor docker was declared required in spec/host-tools.json with no version floor, and host-setup/linux/install-tools.sh carried no install path for it at all (deliberately, per a NOT_MANAGED exception: a hypervisor, a WSL distribution, and a workstation want different answers). Windows already installed Docker Desktop through winget, but nothing checked that WSL2 itself was present and current first. - spec/host-tools.json: docker now carries a target floor of 29.6.2, anchored to the Windows host issue #695 recorded (Docker Desktop 4.85.0, Engine 29.6.2), plus source and remedy for linux, macos and windows. - host-setup/linux/install-tools.sh: adds a real docker install/upgrade path, the same apt-repository shape gh and node already use here - removes old conflicting packages, registers download.docker.com's own repo with a verified signing key, installs docker-ce/docker-ce-cli/containerd.io/docker-buildx-plugin/docker-compose-plugin. Inside a detected WSL distribution it refuses the native install outright, with no override, and points at Docker Desktop's own WSL integration instead. - host-setup/windows/install-tools.ps1: before installing or upgrading docker, checks that wsl.exe is present and WSL is at or above 2.1.5, Docker Desktop's own documented floor, and skips with a named remedy otherwise. It never runs wsl --install or wsl --update itself - those stay upgrade-host.ps1 -Wsl and setup-wsl.ps1, a person's own action, since a WSL platform update restarts every distribution and neither belongs as a side effect of installing a different tool. - scripts/test_bootstrap.py, docs/host-setup.md, host-setup/windows/README.md updated to match: the now-stale NOT_MANAGED['linux']['docker'] exception is removed, the floor table and comparison table are brought current. Verified on this machine: spec/validate.py, scripts/host_gate.py (docker 29.7.1 meets the new floor), scripts/test_bootstrap.py, shellcheck and PSScriptAnalyzer via the pinned CI images, markdownlint-cli2, editorconfig-checker all clean. Exercised --report, --install/--upgrade --dry-run, and a simulated-WSL run against the real download.docker.com repository (GPG key fetch and verification succeeded). A real install/upgrade on a native Windows host and inside an actual WSL distribution is a maintainer follow-up. Co-Authored-By: Claude Sonnet 5 --- docs/host-setup.md | 7 ++- host-setup/linux/install-tools.sh | 76 +++++++++++++++++++++++++++- host-setup/windows/README.md | 4 +- host-setup/windows/install-tools.ps1 | 65 ++++++++++++++++++++++++ scripts/test_bootstrap.py | 11 ++-- spec/host-tools.json | 14 ++++- 6 files changed, 163 insertions(+), 14 deletions(-) diff --git a/docs/host-setup.md b/docs/host-setup.md index f21f2d5c..e77add58 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -20,7 +20,7 @@ This section is the **contract**: which tools a host needs and which repo proced | `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | | Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | | `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | -| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | +| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | **29.6.2**, target | | `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | **0.12.2**, target | The **Floor** column exists because presence and sufficiency are different questions and the answer to the first was being read as the answer to the second. A tool below its floor still answers `--version`, so every other column reports it as fine while `scripts/host_gate.py` fails it. The kind is named beside the number, since a **measured** floor sits above a version known to break a documented procedure and gives a failing host a defect to point at, where a **target** floor names the version the repo's toolchain is configured for and does not. The next section carries the reasoning behind each one. @@ -39,7 +39,9 @@ Presence is the weaker half of this contract. Both host defects this fleet has a **`git-restore-mtime` must not come from it either, where a repo uses it.** Debian and Ubuntu package **2022.12**, which shells out to `git whatchanged`. Current `git` refuses that without a hidden opt-in flag a caller cannot pass through, so the tool restores nothing, prints its ordinary statistics and **exits 0**. A deploy keyed on mtimes then ships a full copy and reports success. Take the upstream release from [git-tools][git-tools-link], or in CI the [action][git-restore-mtime-action-link] that vendors it. Note the direction of that interaction: a **newer** `git` is the trigger rather than the remedy, so a host old enough to still allow `whatchanged` hides the defect rather than avoiding it. No procedure in this repo needs the tool, so the gate declares it **optional** and skips it when absent. -**The rest of the table takes the distribution's package, and two more do not.** `git`, `docker` and the Python interpreter come from the distribution, because each keeps up well enough that a second source buys nothing and costs a repository to trust. `jq` is the same on a current Debian or Ubuntu, which carries a version at or above the floor, and the upstream release binary is the answer only where it does not. `uv` is published by its authors as a release archive and packaged by neither distribution, so upstream is the only source there is. Where a repository needs `node`, the distribution's package trails upstream by whole release lines, so it comes from the NodeSource repository on the line upstream currently carries as long term support. Where a repository needs `dotnet`, the distribution's feed is preferred where it carries an SDK and Microsoft's feed is the fallback, because mixing the two is what breaks a host rather than either one alone, and Microsoft's carries `amd64` only. +**`docker` must not come from the distribution's own package either, with one exception.** Debian and Ubuntu package `docker.io`, an older build that trails and conflicts with `docker-ce`, so [`host-setup/linux/install-tools.sh`][host-setup-dir] removes it and installs from Docker's own apt repository at [download.docker.com][docker-install-link] instead, the same shape it already uses for `gh` and `node`. The exception is a **WSL distribution**, where the only sanctioned source is Docker Desktop's own WSL integration (Settings, Resources, WSL integration, on the Windows side, reported read-only by [`setup-wsl.ps1`][host-setup-windows]) and a native install is refused outright, with no override: running `docker-ce` directly inside a WSL distribution risks a second engine beside Desktop's own. On native **Windows**, `winget` already tracks upstream Desktop releases, so neither hazard arises there. + +**The rest of the table takes the distribution's package, and one more does not.** `git` and the Python interpreter come from the distribution, because each keeps up well enough that a second source buys nothing and costs a repository to trust. `jq` is the same on a current Debian or Ubuntu, which carries a version at or above the floor, and the upstream release binary is the answer only where it does not. `uv` is published by its authors as a release archive and packaged by neither distribution, so upstream is the only source there is. Where a repository needs `node`, the distribution's package trails upstream by whole release lines, so it comes from the NodeSource repository on the line upstream currently carries as long term support. Where a repository needs `dotnet`, the distribution's feed is preferred where it carries an SDK and Microsoft's feed is the fallback, because mixing the two is what breaks a host rather than either one alone, and Microsoft's carries `amd64` only. Neither `node` nor `dotnet` is in the table above, deliberately: they serve the repositories that need them rather than the fleet contract, and a repository needing one declares it in a `host-tools.json` of its own, which [`scripts/host_gate.py`][host-gate] merges over this one. The merge tightens only, so a repository may raise a floor or add one and may not lower or remove one. @@ -305,6 +307,7 @@ A host that fails any row is not ready for the procedure that row names, and the [cli-install-link]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md [cli-link]: https://cli.github.com/ +[docker-install-link]: https://docs.docker.com/engine/install/ [git-restore-mtime-action-link]: https://github.com/chetan/git-restore-mtime-action [git-tools-link]: https://github.com/MestreLion/git-tools [keys-link]: https://github.com/settings/keys diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index bd140c6a..df35f9c8 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -15,7 +15,7 @@ readonly SOURCES_DIR="/etc/apt/sources.list.d" readonly BIN_DIR="/usr/local/bin" # Managed tools, in dependency order: node asks jq to read the upstream release index. -readonly TOOLS=(git gh jq git-restore-mtime node python uv dotnet) +readonly TOOLS=(git gh jq git-restore-mtime node python uv docker dotnet) # Package sets. # The default set is what a tool needs to be useful, and the optional set is what is useful often enough to name but not always wanted, installed only with --optional. @@ -30,6 +30,8 @@ APT_REFRESHED=false APT_DIRTY=false DISTRO_ID="" DISTRO_VERSION="" +CODENAME="" +IS_WSL=false ARCH="" SUDO=() SELECTED=() @@ -98,6 +100,7 @@ detect_host() { DISTRO_ID="${ID:-}" DISTRO_VERSION="${VERSION_ID:-}" + CODENAME="${VERSION_CODENAME:-}" if [[ $DISTRO_ID != "debian" && $DISTRO_ID != "ubuntu" ]]; then [[ " ${ID_LIKE:-} " == *" debian "* ]] || die "Unsupported distribution \"${DISTRO_ID:-unknown}\", this script installs on Debian and Ubuntu based hosts" @@ -108,6 +111,11 @@ detect_host() { command -v apt-get > /dev/null || die "apt-get not found, this script installs apt packages" ARCH=$(dpkg --print-architecture) + # WSL has no kernel of its own, and docker there comes only from Docker Desktop's own WSL integration, never a native install. + if grep -qi microsoft /proc/version 2> /dev/null || [[ -n ${WSL_DISTRO_NAME:-} ]]; then + IS_WSL=true + fi + if [[ $EUID -ne 0 ]]; then command -v sudo > /dev/null || die "Not running as root and sudo is not installed" SUDO=(sudo) @@ -526,6 +534,67 @@ uv_install() { run_root install -m 0755 "$TMP_DIR/uv-$triple/uvx" "$BIN_DIR/uvx" } +# --- docker --- + +docker_source() { printf 'download.docker.com'; } + +# Read directly from the CLI rather than from apt_installed_version docker-ce, unlike gh and node. +# On a WSL distribution using Docker Desktop's own WSL integration, docker is a working command with no docker-ce apt package behind it at all, and reading the apt package version would misreport that working install as absent. +# This also matches exactly what scripts/host_gate.py's own probe and pattern read. +docker_version() { + command -v docker > /dev/null || return 0 + docker --version 2> /dev/null | sed -n 's/^Docker version \([0-9][0-9.]*\).*/\1/p' +} + +# Stripped of the epoch and the Debian package revision apt_candidate_version otherwise carries (e.g. "5:29.7.2-1~debian.13~trixie"), so this compares like for like against docker_version's plain CLI reading rather than against dpkg's own packaging metadata. +docker_target() { + local raw + raw=$(apt_candidate_version docker-ce) + [[ -z $raw ]] && return 0 + raw="${raw#*:}" + printf '%s' "${raw%%-*}" +} + +# Old and conflicting packages named here, per Docker's own uninstall list. +# Debian and Ubuntu never ship a package named docker-ce, so unlike gh and node there is no distro package the upstream one could be confused with, and tool_configured needs no entry for it. +docker_install() { + # The only sanctioned source inside a WSL distribution is Docker Desktop's own WSL integration, confirmed with the maintainer as a hard rule with no override. + # A native install here would run a second engine beside Desktop's, so this is a skip rather than a failure, on the same pattern dotnet_feed uses for an architecture Microsoft's feed does not carry. + if [[ $IS_WSL == true ]]; then + warn "This is a WSL distribution, and docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly. Enable it in Docker Desktop under Settings, Resources, WSL integration, or check it from Windows with setup-wsl.ps1 -Status. Skipping the native install." + return 0 + fi + + local -a conflicts=(docker.io docker-doc docker-compose docker-compose-v2 docker-buildx podman-docker containerd runc) + local -a present=() + local pkg + for pkg in "${conflicts[@]}"; do + package_installed "$pkg" && present+=("$pkg") + done + if [[ ${#present[@]} -gt 0 ]]; then + log " Removing ${#present[@]} conflicting package(s): ${present[*]}" + run_root apt-get remove -y "${present[@]}" + fi + + ensure_prerequisites + remove_stale "$SOURCES_DIR/docker.list" + + if install_keyring "https://download.docker.com/linux/$DISTRO_ID/gpg" \ + "$KEYRING_DIR/docker.asc" \ + "https://download.docker.com/linux/$DISTRO_ID/dists/$CODENAME/InRelease" true; then + APT_DIRTY=true + fi + + if write_sources "docker" "https://download.docker.com/linux/$DISTRO_ID" "$CODENAME" "stable" \ + "$KEYRING_DIR/docker.asc"; then + APT_DIRTY=true + fi + + apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Non-root use (usermod -aG docker $USER) is left to the operator, the same way this file leaves orphaned dependencies to a later apt autoremove: it is a user/group choice rather than a question of whether the tool is present and current. +} + # --- dotnet --- dotnet_source() { @@ -705,6 +774,11 @@ tool_note() { note "$tool" "upstream repository not configured, the available version is the distro's" fi ;; + docker) + if [[ $IS_WSL == true ]]; then + note "docker" "this is a WSL distribution, docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly, so --install/--upgrade skip it" + fi + ;; *) ;; esac diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 931d8770..704d6c7c 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -80,6 +80,8 @@ pwsh -NoProfile -File ..\bootstrap.ps1 -Help Docker's own `docker-desktop` distribution is excluded from every distribution listing, since it is Docker's rather than one an operator installed. +`install-tools.ps1` checks, before installing or upgrading `docker`, that `wsl.exe` is present and reports a WSL version at or above `2.1.5`, Docker Desktop's own documented floor for the platform it depends on. Where either is not the case it skips `docker` and names the exact remedy (`wsl --install --no-distribution`, or `upgrade-host.ps1 -Wsl`) rather than installing against a platform Docker Desktop cannot use, or Windows-feature-installing on the caller's behalf. It never runs `wsl --install` or `wsl --update` itself: those stay `upgrade-host.ps1 -Wsl` and a person's own choice, since an update restarts every distribution and neither belongs as a side effect of installing a different tool. The same check surfaces as a note under `-Report`, read-only, before a caller ever runs `-Install`. + ## Differences From the Linux Tooling | Linux | Windows | Why | @@ -88,7 +90,7 @@ Docker's own `docker-desktop` distribution is excluded from every distribution l | `install-tools.sh` carries four functions per tool | `install-tools.ps1` carries one registry record per tool | Every source is `winget`, so the per-tool variation those functions exist for does not arise | | Actions, the last one given wins | Actions, name one | A `param()` block records which switches were given and not their order, and refusing beats silently discarding an intent | | `git-restore-mtime` is managed | not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path | -| `docker` is not managed | `docker` is managed | `Docker.DockerDesktop` is one winget package, where the Linux answer differs by host role | +| `install-tools.ps1` checks the WSL *platform* version before installing docker | `install-tools.sh` refuses docker entirely inside a WSL *distribution* | Windows needs WSL2 present for Docker Desktop's own backend; a WSL distribution instead takes docker only from Docker Desktop's own WSL integration | | `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | | `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | | `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 5965c193..85ddb3c4 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -434,6 +434,60 @@ function Add-ToolNote { if ($Tool.Name -eq 'dotnet' -and -not $script:WITH_OPTIONAL) { note 'dotnet' "optional set not selected: $($Tool.Optional -join ', ')" } + if ($Tool.Name -eq 'docker') { + $wslProblem = Test-WslReadyForDocker + if ($wslProblem) { note 'docker' $wslProblem } + } +} + +# --- WSL --- + +# Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. +$DOCKER_WSL_FLOOR = '2.1.5' + +# Every wsl.exe call goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. +# Mirrored from upgrade-host.ps1 rather than shared with it, on the same rule as the rest of this directory: a script here has to stay independently fetchable. +function Invoke-Wsl { + param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) + if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } + return $text + } finally { + if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } + else { $env:WSL_UTF8 = $previous } + } +} + +# Just the "WSL version" line, as a value Compare-HostVersion can read. +# upgrade-host.ps1's Get-WslVersion concatenates WSL, kernel and WSLg into one display string instead, which serves a report and not a comparison. +function Get-WslPlatformVersion { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } + $text = Invoke-Wsl '--version' + if ($LASTEXITCODE -ne 0) { return $null } + if ($text -match '(?m)^WSL version:\s*(\S+)\s*$') { return $Matches[1] } + return $null +} + +# What stands between this host and installing or upgrading docker, or $null where nothing does. +# Read-only: this never runs wsl --install or wsl --update itself. Those stay a person's own action +# through upgrade-host.ps1 -Wsl and setup-wsl.ps1, confirmed with the maintainer as the boundary, +# since a WSL platform update restarts every distribution and neither action belongs as a silent +# side effect of installing a different tool. +function Test-WslReadyForDocker { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" + } + $version = Get-WslPlatformVersion + if (-not $version) { + return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later" + } + if ((Compare-HostVersion $version $script:DOCKER_WSL_FLOOR) -lt 0) { + return "WSL is at $version, and Docker Desktop needs $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" + } + return $null } # --- Actions --- @@ -494,6 +548,17 @@ function Invoke-ToolApply { return } + # Docker Desktop needs WSL2 already present and current, and does not install or update it itself. + # A WSL gap has nothing to do with any other tool in this run, so it is collected here on the same rule as a failed install rather than ending the whole run. + if ($ToolName -eq 'docker') { + $wslProblem = Test-WslReadyForDocker + if ($wslProblem) { + warn "docker skipped, $wslProblem" + $script:FAILED += $ToolName + return + } + } + # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { diff --git a/scripts/test_bootstrap.py b/scripts/test_bootstrap.py index f59e3aa9..1dcc56d6 100644 --- a/scripts/test_bootstrap.py +++ b/scripts/test_bootstrap.py @@ -16,9 +16,7 @@ That assertion runs once per platform, because the two installers do not manage the same set and the difference is a decision rather than an accident. `git-restore-mtime` serves a Linux deploy path and -the spec declares it not applicable on Windows. `docker` is the reverse: one winget package there, -and on Linux an answer that differs by whether the host is a hypervisor, a WSL distribution, or a -workstation. +the spec declares it not applicable on Windows. Run: python3 scripts/test_bootstrap.py """ @@ -61,12 +59,9 @@ WINDOWS_INSTALLER_REMEDY = re.compile(r'^host-setup/windows/install-tools\.ps1 -Upgrade (\S+)$') # A spec tool an installer deliberately does not manage, and the reason, recorded so an omission is a decision somebody made rather than one nobody noticed. -# The windows set is empty rather than absent, which is itself the assertion: Docker Desktop is one winget package there, where on Linux a hypervisor and a workstation want different answers, so an entry appearing here later is a decision to justify rather than a gap to fill. +# Both sets are empty, which is itself the assertion: docker installs the same way on a hypervisor and a workstation on both platforms now, and the one case that differs, a WSL distribution, is handled inside install-tools.sh itself (it skips the native install and points at Docker Desktop's own WSL integration) rather than by leaving docker unmanaged on Linux entirely. NOT_MANAGED = { - 'linux': { - 'docker': 'Installed from the vendor script per the distribution, and a hypervisor or a WSL ' - 'distribution wants a different answer than a workstation does.', - }, + 'linux': {}, 'windows': {}, } diff --git a/spec/host-tools.json b/spec/host-tools.json index c8ed655e..45b29917 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -7,8 +7,18 @@ "required": true, "probes": [["docker", "--version"]], "pattern": "Docker version (\\d+(?:\\.\\d+)*)", - "minimum": null, - "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run. No floor is declared because none has been measured here." + "minimum": "29.6.2", + "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run, and this floor is a target rather than a measured breakage for exactly that reason. It is anchored to what one host already carries, the same reasoning jq's and uv's floors use: issue #695 recorded Docker Desktop 4.85.0 and Engine 29.6.2 on a Windows host, and the probe reads the engine version, not the Desktop application version winget tracks separately, so the floor is written against the number the probe actually sees. A host below it is therefore unverified rather than known broken. Inside a WSL distribution, docker comes from Docker Desktop's own WSL integration rather than a local package, so a below-floor reading there is fixed by upgrading Docker Desktop on the Windows host that distribution runs under, not by the Linux remedy below, which install-tools.sh itself refuses to act on inside a WSL distribution.", + "source": { + "linux": "Docker's own apt repository at download.docker.com, per https://docs.docker.com/engine/install/, and never the distribution's docker.io package, which trails upstream and conflicts with docker-ce. Not applicable inside a WSL distribution, which takes docker only from Docker Desktop's own WSL integration.", + "macos": "Docker Desktop for Mac, via Homebrew Cask, which tracks upstream Desktop releases.", + "windows": "winget (Docker.DockerDesktop), which tracks upstream Desktop releases." + }, + "remedy": { + "linux": "host-setup/linux/install-tools.sh --upgrade docker", + "macos": "brew install --cask docker", + "windows": "host-setup/windows/install-tools.ps1 -Upgrade docker" + } }, { "name": "gh", From 8e3cd9e5de077677845fef51f6f0e00bc6998044 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 08:00:36 -0700 Subject: [PATCH 2/4] Fix docker CI failure and address Copilot review findings scripts/test_host_gate.py asserted a fixed set of tools carrying a declared floor and a fixed set of target-floor entries, both hardcoded and missed by the exploration before this change - this is what CI's Lint sources job actually failed on. docker is added to both. Also addresses PR #701's Copilot review: - host-setup/linux/install-tools.sh: docker_install now fails fast with a named cause when /etc/os-release carries no VERSION_CODENAME, instead of building a broken repository URL. - host-setup/linux/install-tools.sh: the dearmored signing key is written as docker.gpg rather than docker.asc, matching the nodesource.gpg naming already used for a binary keyring here. - spec/host-tools.json: docker's why text reworded so the WSL exception describes what the printed Linux remedy actually does (refuses and re-points) rather than reading as though a different remedy should have been shown. - host-setup/windows/install-tools.ps1: Test-WslReadyForDocker's unparseable-version branch now names the same upgrade-host.ps1 -Wsl remedy the below-floor branch already gives. Re-verified: scripts/test_host_gate.py, scripts/host_gate.py, scripts/test_bootstrap.py, spec/validate.py, shellcheck and PSScriptAnalyzer via the pinned CI images, markdownlint-cli2, editorconfig-checker all clean. Re-ran the docker dry-run and simulated-WSL checks on this machine to confirm docker.gpg and the codename guard behave as intended. Co-Authored-By: Claude Sonnet 5 --- host-setup/linux/install-tools.sh | 6 ++++-- host-setup/windows/install-tools.ps1 | 2 +- scripts/test_host_gate.py | 10 +++++----- spec/host-tools.json | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index df35f9c8..b0adbdb0 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -564,6 +564,8 @@ docker_install() { warn "This is a WSL distribution, and docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly. Enable it in Docker Desktop under Settings, Resources, WSL integration, or check it from Windows with setup-wsl.ps1 -Status. Skipping the native install." return 0 fi + [[ -n $CODENAME ]] || + die "/etc/os-release names no VERSION_CODENAME, so the Docker apt repository's suite cannot be worked out" local -a conflicts=(docker.io docker-doc docker-compose docker-compose-v2 docker-buildx podman-docker containerd runc) local -a present=() @@ -580,13 +582,13 @@ docker_install() { remove_stale "$SOURCES_DIR/docker.list" if install_keyring "https://download.docker.com/linux/$DISTRO_ID/gpg" \ - "$KEYRING_DIR/docker.asc" \ + "$KEYRING_DIR/docker.gpg" \ "https://download.docker.com/linux/$DISTRO_ID/dists/$CODENAME/InRelease" true; then APT_DIRTY=true fi if write_sources "docker" "https://download.docker.com/linux/$DISTRO_ID" "$CODENAME" "stable" \ - "$KEYRING_DIR/docker.asc"; then + "$KEYRING_DIR/docker.gpg"; then APT_DIRTY=true fi diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 85ddb3c4..e6142443 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -482,7 +482,7 @@ function Test-WslReadyForDocker { } $version = Get-WslPlatformVersion if (-not $version) { - return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later" + return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" } if ((Compare-HostVersion $version $script:DOCKER_WSL_FLOOR) -lt 0) { return "WSL is at $version, and Docker Desktop needs $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index 8dbb2105..767cb2f7 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -656,7 +656,7 @@ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): one in the data, which is what caught the python3 floor being added without this line. """ floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} - self.assertEqual(floors, {'gh', 'git-restore-mtime', 'jq', 'python3', 'uv'}) + self.assertEqual(floors, {'docker', 'gh', 'git-restore-mtime', 'jq', 'python3', 'uv'}) def test_the_contract_table_carries_every_declared_floor(self): """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves. @@ -714,11 +714,11 @@ def test_a_target_floor_says_so_rather_than_implying_a_defect(self): A reader who takes a target floor for a measured one goes looking for a defect report that does not exist, which is the failure the two-kinds wording was written to prevent. - The set is asserted rather than one entry, so a third target floor added without the wording - fails here instead of reading as measured. Both members are named, since a check that only - counted them would pass on the wrong pair. + The set is asserted rather than one entry, so a target floor added without the wording + fails here instead of reading as measured. Each one is named individually, since a check + that only counted them would pass on a wrong substitution. """ - targets = {'jq': '1.7', 'python3': '3.13'} + targets = {'docker': '29.6.2', 'jq': '1.7', 'python3': '3.13'} by_name = {t['name']: t for t in self.data['tools']} for name, floor in targets.items(): entry = by_name[name] diff --git a/spec/host-tools.json b/spec/host-tools.json index 45b29917..d8f12f6b 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -8,7 +8,7 @@ "probes": [["docker", "--version"]], "pattern": "Docker version (\\d+(?:\\.\\d+)*)", "minimum": "29.6.2", - "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run, and this floor is a target rather than a measured breakage for exactly that reason. It is anchored to what one host already carries, the same reasoning jq's and uv's floors use: issue #695 recorded Docker Desktop 4.85.0 and Engine 29.6.2 on a Windows host, and the probe reads the engine version, not the Desktop application version winget tracks separately, so the floor is written against the number the probe actually sees. A host below it is therefore unverified rather than known broken. Inside a WSL distribution, docker comes from Docker Desktop's own WSL integration rather than a local package, so a below-floor reading there is fixed by upgrading Docker Desktop on the Windows host that distribution runs under, not by the Linux remedy below, which install-tools.sh itself refuses to act on inside a WSL distribution.", + "why": "The four linters run as pinned images rather than local installs, so the image tag fixes the linter version and the daemon's own version has never been the thing that broke a run, and this floor is a target rather than a measured breakage for exactly that reason. It is anchored to what one host already carries, the same reasoning jq's and uv's floors use: issue #695 recorded Docker Desktop 4.85.0 and Engine 29.6.2 on a Windows host, and the probe reads the engine version, not the Desktop application version winget tracks separately, so the floor is written against the number the probe actually sees. A host below it is therefore unverified rather than known broken. Inside a WSL distribution, docker comes from Docker Desktop's own WSL integration rather than a local package, and the Linux remedy below reflects that: run inside a WSL distribution, it refuses the native install and points at enabling that integration and at upgrading Docker Desktop on the Windows host the distribution runs under, rather than installing anything itself.", "source": { "linux": "Docker's own apt repository at download.docker.com, per https://docs.docker.com/engine/install/, and never the distribution's docker.io package, which trails upstream and conflicts with docker-ce. Not applicable inside a WSL distribution, which takes docker only from Docker Desktop's own WSL integration.", "macos": "Docker Desktop for Mac, via Homebrew Cask, which tracks upstream Desktop releases.", From 810133c89463594d0ff8daa2cabca2e310d3db13 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 08:09:06 -0700 Subject: [PATCH 3/4] Address suppressed Copilot findings and this repo's own prose lint Two suppressed (low-confidence) findings from PR #701's review, both real: - host-setup/linux/install-tools.sh: docker_install on a WSL distribution now returns failure when docker is not on PATH there either, rather than always reporting success. A skip is only success where Docker Desktop's own WSL integration already answers - otherwise --install/ --upgrade was exiting 0 having neither installed docker nor found it working, which hid an unmet contract from a caller checking the exit code. - host-setup/windows/README.md: the docker/WSL comparison table row had its Linux and Windows columns swapped (install-tools.ps1 described under Linux, install-tools.sh under Windows). Also fixes what scripts/prose_lint.py itself flags on the new install-tools.ps1 comments (not run locally before the prior two pushes, which is why this took a third round): one sentence per comment line rather than wrapped across several, and no sentence opening on a lowercase filename. And one semicolon in the same README row, which this repo's prose rules treat as two sentences or a comma. Re-verified: scripts/prose_lint.py, scripts/repo_gate.py --check eol/eol-coverage (not run in either prior round), shellcheck, PSScriptAnalyzer, markdownlint-cli2, editorconfig-checker, spec/validate.py, scripts/host_gate.py, scripts/test_bootstrap.py, scripts/test_host_gate.py all clean. Simulated a WSL distribution with docker genuinely absent from PATH to confirm the new failure path actually exits 1 and names docker as failed, and re-ran the WSL-with-docker- present and plain-outdated dry-run paths to confirm neither regressed. Co-Authored-By: Claude Sonnet 5 --- host-setup/linux/install-tools.sh | 7 +++++-- host-setup/windows/README.md | 2 +- host-setup/windows/install-tools.ps1 | 9 ++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index b0adbdb0..688c4d29 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -559,10 +559,13 @@ docker_target() { # Debian and Ubuntu never ship a package named docker-ce, so unlike gh and node there is no distro package the upstream one could be confused with, and tool_configured needs no entry for it. docker_install() { # The only sanctioned source inside a WSL distribution is Docker Desktop's own WSL integration, confirmed with the maintainer as a hard rule with no override. - # A native install here would run a second engine beside Desktop's, so this is a skip rather than a failure, on the same pattern dotnet_feed uses for an architecture Microsoft's feed does not carry. + # A native install here would run a second engine beside Desktop's, so this is always a skip rather than an install, on the same pattern dotnet_feed uses for an architecture Microsoft's feed does not carry. if [[ $IS_WSL == true ]]; then warn "This is a WSL distribution, and docker here comes only from Docker Desktop's own WSL integration, never from installing docker-ce directly. Enable it in Docker Desktop under Settings, Resources, WSL integration, or check it from Windows with setup-wsl.ps1 -Status. Skipping the native install." - return 0 + # A skip is success only where the integration already answers, since --install/--upgrade otherwise exits 0 having neither installed docker nor found it working. + command -v docker > /dev/null && return 0 + warn "docker is not on PATH here either, so Docker Desktop's WSL integration is not enabled for this distribution yet." + return 1 fi [[ -n $CODENAME ]] || die "/etc/os-release names no VERSION_CODENAME, so the Docker apt repository's suite cannot be worked out" diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 704d6c7c..9a561dea 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -90,7 +90,7 @@ Docker's own `docker-desktop` distribution is excluded from every distribution l | `install-tools.sh` carries four functions per tool | `install-tools.ps1` carries one registry record per tool | Every source is `winget`, so the per-tool variation those functions exist for does not arise | | Actions, the last one given wins | Actions, name one | A `param()` block records which switches were given and not their order, and refusing beats silently discarding an intent | | `git-restore-mtime` is managed | not managed | The spec declares it not applicable on Windows, since it serves a Linux deploy path | -| `install-tools.ps1` checks the WSL *platform* version before installing docker | `install-tools.sh` refuses docker entirely inside a WSL *distribution* | Windows needs WSL2 present for Docker Desktop's own backend; a WSL distribution instead takes docker only from Docker Desktop's own WSL integration | +| `install-tools.sh` refuses docker entirely inside a WSL *distribution* | `install-tools.ps1` checks the WSL *platform* version before installing docker | A WSL distribution takes docker only from Docker Desktop's own WSL integration, and Windows needs WSL2 present for Docker Desktop's own backend | | `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures | | `unmanaged` means the upstream repository is unconfigured | `unmanaged` means the tool is on `PATH` and winget knows no package for it | The same question, by a different mechanism | | `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows | diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index e6142443..156e68ad 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -462,7 +462,7 @@ function Invoke-Wsl { } # Just the "WSL version" line, as a value Compare-HostVersion can read. -# upgrade-host.ps1's Get-WslVersion concatenates WSL, kernel and WSLg into one display string instead, which serves a report and not a comparison. +# Get-WslVersion in upgrade-host.ps1 concatenates WSL, kernel and WSLg into one display string instead, which serves a report rather than a comparison. function Get-WslPlatformVersion { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } $text = Invoke-Wsl '--version' @@ -472,10 +472,9 @@ function Get-WslPlatformVersion { } # What stands between this host and installing or upgrading docker, or $null where nothing does. -# Read-only: this never runs wsl --install or wsl --update itself. Those stay a person's own action -# through upgrade-host.ps1 -Wsl and setup-wsl.ps1, confirmed with the maintainer as the boundary, -# since a WSL platform update restarts every distribution and neither action belongs as a silent -# side effect of installing a different tool. +# Read-only: this never runs wsl --install or wsl --update itself. +# Those stay a person's own action through upgrade-host.ps1 -Wsl and setup-wsl.ps1, confirmed with the maintainer as the boundary. +# A WSL platform update restarts every distribution, and neither action belongs as a silent side effect of installing a different tool. function Test-WslReadyForDocker { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" From d1cee0af80d4e64ffac74f7443ff8163f5ad4965 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 08:15:07 -0700 Subject: [PATCH 4/4] Make docker_source WSL-aware, matching what docker_install actually does docker_source always reported download.docker.com, even inside a WSL distribution where docker_install refuses that path outright and points at Docker Desktop's own WSL integration instead - so the --report/--list SOURCE column was misleading exactly where the WSL note already explains the real source. docker_source now reads IS_WSL the same way docker_install does, mirroring the existing dotnet_source precedent for a source that depends on host state. Re-verified: prose_lint.py, shellcheck, repo_gate.py --check eol/eol-coverage, spec/validate.py, host_gate.py, test_bootstrap.py, test_host_gate.py all clean. Confirmed --list (no host detection yet) still reads download.docker.com, a plain --report reads download.docker.com, and a WSL_DISTRO_NAME-simulated --report reads "Docker Desktop's WSL integration". Co-Authored-By: Claude Sonnet 5 --- host-setup/linux/install-tools.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/host-setup/linux/install-tools.sh b/host-setup/linux/install-tools.sh index 688c4d29..5fe4d1b8 100755 --- a/host-setup/linux/install-tools.sh +++ b/host-setup/linux/install-tools.sh @@ -536,7 +536,14 @@ uv_install() { # --- docker --- -docker_source() { printf 'download.docker.com'; } +# Matches docker_install: a WSL distribution never reaches download.docker.com, so the report says where docker actually comes from there instead. +docker_source() { + if [[ $IS_WSL == true ]]; then + printf "Docker Desktop's WSL integration" + else + printf 'download.docker.com' + fi +} # Read directly from the CLI rather than from apt_installed_version docker-ce, unlike gh and node. # On a WSL distribution using Docker Desktop's own WSL integration, docker is a working command with no docker-ce apt package behind it at all, and reading the apt package version would misreport that working install as absent.