Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,20 @@ Three findings raised while writing [`host-setup/windows/`][host-setup-windows],
- **Settled** - The change is four `usage()` heredocs and four `parse_args()` bodies now that `bootstrap.sh` carries the same contract, and taking it deletes the differences-table row in [`host-setup/windows/README.md`][host-setup-windows] rather than leaving a permanent divergence.
- **Open** - Nothing.

### Whether a Removed App Execution Alias Comes Back

Removing the `python.exe` and `python3.exe` app execution alias stubs frees the name, which is measured. What is not measured is whether Windows puts them back. The Settings page keeps reporting both aliases as `On` after the files are gone, so the declared state and the on-disk state diverge, and nothing found so far says which one servicing reads. This needs a human to log out and back in, and to reboot, and to report whether the enabled copies reappear in the WindowsApps directory.

**State** `measure`. **Touches** [`host-setup/windows/install-tools.ps1`][install-tools-ps1] and [`docs/host-setup.md`][host-setup-doc], both of which currently say the alias *can* return rather than that it does. **Cost** one logout and one reboot on a Windows host, then a one-line hub edit either way.

- **Log out and back in, then reboot, and report whether the enabled alias copies return.** The check is whether `python.exe` and `python3.exe` exist again directly under the WindowsApps directory, which is the copy that sits on `PATH`. The package's own copies under the App Installer subdirectory are always present and are not the answer.
- **Blocked by** - Nothing, beyond the maintainer having a moment to log out and reboot.
- **Issue** - [#1161][issue-1161], which this cluster's own change closes. This measurement is what remains after it.
- **Checked** - Branch `python3-on-windows` on 2026-09-01, where the aliases were toggled on from Settings, removed by `install-tools.ps1 -Install python`, confirmed gone from the WindowsApps directory, and confirmed still reported `On` by the Settings page after that page was closed and reopened. No logout or reboot has followed, so recreation is untested rather than ruled out.
- **Settled** - Removing the file is the only mechanism available. The store the Settings toggle reads was searched for under `AppModel\SystemAppData` and the `AppModel` repository and was not found, and writing a registry location nobody has identified is the guessing the write-safety rules already forbid.
- **Settled** - The installer re-removes on every apply rather than assuming a host stays fixed, so recreation is self-healing on the next run either way, and turning the two toggles off in Settings is the durable fix a person can apply.
- **Open** - Whether the prose drops to "does not return" or hardens to "returns on every logon", which is the one thing the measurement decides.

### Neither Host Bootstrap Has Run Against a Truly Fresh Host

Two loaders exist so a copy-paste snippet takes a stock OS install to a configured dev host, and neither has ever been run that way. Everything either has behind it is a dry run or a read against an already-configured checkout, on a machine carrying most of the target tools already. That confirms the logic is internally consistent. It confirms nothing about a `winget` package id still resolving, a stock Debian netinst actually lacking `curl` the way the docs assume, `tar.exe` genuinely shipping on a given Windows image, or the interactive menu reading correctly on a real console. This needs a human watching a real run on a real fresh image and reporting back what broke, including anything that merely looked fine, since neither of those closes from a description of the logic.
Expand Down Expand Up @@ -526,6 +540,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w
[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673
[issue-767]: https://github.com/ptr727/ProjectTemplate/issues/767
[issue-931]: https://github.com/ptr727/ProjectTemplate/issues/931
[issue-1161]: https://github.com/ptr727/ProjectTemplate/issues/1161

<!-- Pull requests -->

Expand Down Expand Up @@ -554,6 +569,7 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w
[host-setup-doc]: ./docs/host-setup.md
[host-setup-windows]: ./host-setup/windows/
[install-tools]: ./host-setup/linux/install-tools.sh
[install-tools-ps1]: ./host-setup/windows/install-tools.ps1
[line-endings-ref]: ./.agents/skills/comment-and-doc-style/references/line-endings.md
[markdownlint]: ./.markdownlint-cli2.jsonc
[matrix]: ./reports/conformance-matrix.md
Expand Down
21 changes: 13 additions & 8 deletions docs/host-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This section is the **contract**: which tools a host needs and which repo proced
| --- | --- | --- | --- |
| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none |
| `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 |
| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version` | **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 |
| Ripgrep | repository searches and file discovery by coding agents | `rg --version` | **13.0.0**, target |
| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | **29.6.2**, target |
Expand All @@ -28,13 +28,17 @@ The **Floor** column exists because presence and sufficiency are different quest

Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check.

**The interpreter is not called `python3` everywhere.** On native Windows the installer registers `python`, `py` and `python3.13` but **not** `python3`, where that name instead resolves to a Microsoft Store alias stub that reports the interpreter as missing, so a correctly set-up host fails a `python3` check. Stock Debian is the mirror image, carrying `python3` and no bare `python`. Use `py -3` on native Windows and `python3` elsewhere, and prefer `python3` in any script that must run on both, since WSL2 shadows the Windows stub.
**The interpreter is called `python3` everywhere, and on native Windows that is something the tooling provides rather than something the platform ships.** Windows registers `python` and `py` through the interpreter's own installer, and gives `python3` by default to an *app execution alias stub*, a zero-byte reparse point in the WindowsApps directory belonging to App Installer, which only ever offers to open the Microsoft Store. A Microsoft Store Python registers aliases under those same two names carrying the same reparse tag, and those reach a real interpreter somebody chose, so the target package rather than the tag is what tells a placeholder from a working setup. The installer removes only the placeholder and reports anything else. Measured on a fleet Windows host, that stub writes its `Python was not found` message to **stderr** and exits **9009**, so it fails loudly and a `&&` chain does stop on it. What it does instead is occupy the name: WindowsApps is on `PATH` by default, so wherever it sits ahead of the install directory the stub answers and the installed interpreter cannot be reached under that name at all. [`host-setup/windows/install-tools.ps1`][install-tools-windows] fixes both halves when it manages the `python` tool: it removes those stubs, and it puts a real `python3` beside the interpreter it installed. Stock Debian is the mirror image, carrying `python3` and no bare `python`, so `python3` is the one name that is correct on all three platforms and is what every script and brief should use.

**Removing a stub is not the same act as the Settings toggle, and the toggle keeps reading `On` afterwards.** The alias has two layers: the package's own copy under the App Installer subdirectory of WindowsApps, which `winget.exe` sits beside and which the installer never touches, and the enabled copy in WindowsApps itself, which is the one on `PATH` and the one the installer removes. Removing it frees the name immediately, measured rather than assumed. But the store the Settings page reads is not that file and no documented interface writes it, so the page still shows `On`, and the alias can return when App Installer is next serviced. `install-tools.ps1` re-removes it on every apply rather than assuming a host stays fixed, and turning the two toggles off in Settings is what stops it coming back at all.

**Two further Windows states survive that and are reported rather than fixed.** A `python3` from MSYS2, Cygwin or Scoop earlier on `PATH` is a real interpreter that answers cleanly, so it is the wrong one silently rather than loudly. `install-tools.ps1 -Report python` names it, and `PATH` order is the only thing deciding it. And an activated virtual environment on Windows carries `python.exe` but no `python3.exe`, so a bare `python3` there reaches the host interpreter rather than the environment's. Use `python` inside an environment, which is the interpreter an environment puts first on purpose.

A missing tool is a host gap, not a repo problem. Install it and re-run, rather than working around it in a repo.

### Where a Tool Comes From, and How Old It May Be

Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on.
Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Either kind is a floor and not a ceiling: the platform rather than this repo decides how high a host can go, since a Linux distribution ships the line it ships, so a host at the floor and a host above it both pass. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on.

**`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows** `winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own.

Expand Down Expand Up @@ -306,11 +310,11 @@ gh auth status

The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy.

**This block is POSIX, and on native Windows two lines need translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same Store alias stub it does in PowerShell and reports a working interpreter as missing.
**This block is POSIX, and on native Windows one line needs translating.** Run the POSIX form from WSL2 or Git Bash per the shell note, or use the PowerShell form below. Git Bash inherits the Windows `PATH`, so `python3` reaches the same interpreter there that it does in PowerShell, which is the point of [`install-tools.ps1`][install-tools-windows] supplying that name in the first place.

```powershell
py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json
py -3 scripts/skills_install.py --report # the skills install stamp is current
python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json
python3 scripts/skills_install.py --report # the skills install stamp is current
git config --global --list | Select-String "user\.|signing|gpg\."
$d = Join-Path $env:TEMP ([guid]::NewGuid())
try {
Expand All @@ -331,9 +335,9 @@ try {
gh auth status
```

Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `py -3 scripts/host_gate.py` exits 0 over seven declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. Only two lines differ from the POSIX block: the interpreter, and the filter, because `grep` has no Windows peer and `Select-String` is the one that ships.
Verified on Windows 11 Pro 10.0.26200 with PowerShell 7.6.4, where `python3 scripts/host_gate.py` exits 0 over eight declared tools. It was supplied under [#483][issue-483], which had deferred it until somebody had executed it on a Windows host. One line differs from the POSIX block, the filter, because `grep` has no Windows peer and `Select-String` is the one that ships.

**`py -3` rather than `python`, and the reason is not only the Store stub.** Both names reach the same interpreter on a correctly set-up host, so the stub rules out `python3` and chooses nothing between the other two. What chooses is that an activated virtual environment puts its own interpreter first, so `python` resolves to that environment's. That is right for running project code and wrong here, because this gate measures **the host's** interpreter against a floor, and run as `python` from an activated environment it grades the environment instead. `py` is the launcher and reaches a registered system interpreter whatever is active. The prescription is therefore narrow: `py -3` for this gate, and `python` for everything else.
**The gate probes `py -3` before `python3`, which is the one place that name survives.** Which interpreter *runs* `host_gate.py` does not decide what it measures, because the gate reads its answer from its own probes rather than from itself. What those probes must avoid is grading something other than the host: a bare `python3` is reached through `PATH`, where an activated virtual environment or a foreign interpreter from MSYS2, Cygwin or Scoop answers ahead of the managed one. `py` is the launcher, reaches a registered system interpreter whatever is active, and exists on no other platform, so it answers first on Windows and not at all on Linux or macOS, which leaves the `python3` probe to answer for those. The prescription is therefore narrow and internal: `py -3` is the gate's first probe, and `python3` is the name everything else uses.

**What the host can do once this passes**, which is the point of the contract above:

Expand Down Expand Up @@ -368,6 +372,7 @@ A host that fails any row is not ready for the procedure that row names, and the
[host-setup-dir]: ../host-setup/
[host-setup-windows]: ../host-setup/windows/
[host-tools]: ../spec/host-tools.json
[install-tools-windows]: ../host-setup/windows/install-tools.ps1
[issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483
[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781
[operations]: ../OPERATIONS.md
Expand Down
1 change: 1 addition & 0 deletions host-setup/windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ This moved a boundary the tool used to hold: WSL used to be read-only here, and
| `sudo` re-runs a command as root | nothing elevates | `winget` raises UAC per installer, which is the path with the fewest failures |
| `install-tools.sh --sudo-timestamp` shares one sudo credential cache across a user's terminals | no peer | Nothing here elevates on Windows, so there is no cached credential to share |
| `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 |
| `python-is-python3` supplies the `python` name, under `--optional` | `install-tools.ps1` supplies the `python3` name itself, always | Debian packages the alias and Windows does not, so the one platform installs a package and the other writes the file. This is the only action here that deletes something winget did not install, so it is narrow by construction: it removes an app execution alias only where the reparse tag and the target package both say it is the Microsoft Store placeholder, and reports rather than removes anything else |
| `credential.helper cache --timeout=3600` | `credential.helper manager`, and only where unset | Git Credential Manager ships with Git for Windows |
| `ssh-agent` is a socket, started per shell | `ssh-agent` is a Windows service, reported and not started | Starting it needs administrator, and nothing here elevates |
| no WSL script | `setup-wsl.ps1` | WSL is a Windows-side concern with no Linux-side peer |
Expand Down
Loading