diff --git a/.circleci/config.yml b/.circleci/config.yml index 610ddf508b..fef9d7e00a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -201,6 +201,58 @@ jobs: - save_cache: key: go-freebsd-v1-{{ checksum "third_party/go/BUILD" }} paths: [ ".plz-cache/third_party/go" ] + + build-windows: + working_directory: ~/please + docker: + - image: ghcr.io/thought-machine/please_windows_builder:20260910 + resource_class: large + steps: + - checkout + - attach_workspace: + at: /tmp/workspace + - restore_cache: + key: go-windows-v1-{{ checksum "third_party/go/BUILD" }} + - run: + name: Extract plz + command: tar -xzf /tmp/workspace/linux_amd64/please_*.tar.gz + - run: + name: Cross-compile + command: ./please/please build -p -v2 --profile ci --arch windows_amd64 //package:release_files + - persist_to_workspace: + root: plz-out/pkg + paths: + - windows_amd64/* + - store_artifacts: + path: plz-out/log + - save_cache: + key: go-windows-v1-{{ checksum "third_party/go/BUILD" }} + paths: [ ".plz-cache/third_party/go" ] + + # Runs the Windows binaries we just cross-built, under Wine, on Linux. This is the only + # thing in CI that checks the port's runtime behaviour rather than that it compiles; see + # docs/design/windows/05-testing-strategy.md for what it does and does not cover. + test-windows-wine: + working_directory: ~/please + docker: + - image: ghcr.io/thought-machine/please_windows_builder:20260910 + resource_class: large + steps: + - checkout + - attach_workspace: + at: /tmp/workspace + - restore_cache: + key: go-windows-v1-{{ checksum "third_party/go/BUILD" }} + - run: + name: Extract plz + command: tar -xzf /tmp/workspace/linux_amd64/please_*.tar.gz + - run: + name: Test under Wine + command: ./please/please test -p -v2 --profile ci --include=wine + - store_test_results: + path: plz-out/log/test_results.xml + - store_artifacts: + path: plz-out/log build-linux-arm64: working_directory: ~/please docker: @@ -515,6 +567,12 @@ workflows: - build-freebsd: requires: - build-alpine + - build-windows: + requires: + - build-alpine + - test-windows-wine: + requires: + - build-alpine - test-rex: requires: - build-alpine @@ -531,6 +589,8 @@ workflows: requires: - build-alpine - build-freebsd + - build-windows + - test-windows-wine - build-darwin-amd64 - build-linux-arm64 - build-linux diff --git a/.circleci/release.sh b/.circleci/release.sh index f51286bd49..86ed5fba23 100755 --- a/.circleci/release.sh +++ b/.circleci/release.sh @@ -50,6 +50,7 @@ release_folder /tmp/workspace/darwin_arm64 darwin_arm64/$VERSION release_folder /tmp/workspace/linux_amd64 linux_amd64/$VERSION release_folder /tmp/workspace/linux_arm64 linux_arm64/$VERSION release_folder /tmp/workspace/freebsd_amd64 freebsd_amd64/$VERSION +release_folder /tmp/workspace/windows_amd64 windows_amd64/$VERSION # Sign the download script with our release key /tmp/workspace/release_signer pgp -o get_plz.sh.asc -i tools/misc/get_plz.sh @@ -58,6 +59,14 @@ release_file tools/misc/get_plz.sh get_plz.sh text/x-shellscript release_file get_plz.sh.asc get_plz.sh.asc text/plain release_file get_plz.sh.sig get_plz.sh.sig application/octet-stream +# The Windows installer, served the same way and signed the same way. Windows has no shell to +# curl | sh with; this is run with irm ... | iex instead. +/tmp/workspace/release_signer pgp -o get_plz.ps1.asc -i tools/misc/get_plz.ps1 +/tmp/workspace/release_signer kms -o get_plz.ps1.sig -i tools/misc/get_plz.ps1 +release_file tools/misc/get_plz.ps1 get_plz.ps1 text/plain +release_file get_plz.ps1.asc get_plz.ps1.asc text/plain +release_file get_plz.ps1.sig get_plz.ps1.sig application/octet-stream + if [[ "$VERSION" == *"beta"* ]] || [[ "$VERSION" == *"alpha"* ]] || [[ "$VERSION" == *"prerelease"* ]]; then echo "$VERSION is a prerelease, only setting latest_prerelease_version" else diff --git a/.gitattributes b/.gitattributes index 050d731e99..1d6e67abec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ -*.build_defs linguist-language=Starlark diff=python -BUILD.plz linguist-language=Starlark diff=python -docs/* linguist-documentation -*_bindata.go linguist-generated -third_party/go/zip/* linguist-vendored +# Batch files are read by cmd.exe, which is not reliable on LF-only input, and the fixtures +# under test/windows are compared byte for byte against output the bundled busybox produced. +# Neither should be translated on checkout. +*.cmd -text +src/run/test_data/** -text +test/windows/smoke_repo/** -text +test/windows/offline_repo/** -text diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..10857e0e19 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +# Builds and publishes a release from this fork. +# +# Upstream publishes to a GCS bucket from CircleCI, which only runs on thought-machine/please. +# A fork cannot drive that, so nothing this branch produces was downloadable by pleasew.ps1, +# get_plz.ps1 or plz update - the Windows support was real and unreachable at the same time. +# +# This publishes the same artifacts as a GitHub Release instead. The asset names carry the +# platform, which is what gen_release.py does for the GitHub half of an upstream release, so the +# two layouts agree on names even though they disagree on paths. +# +# Delete this whole workflow if upstream ever starts publishing Windows builds. +name: Release + +on: + push: + tags: + - "v*" + # So a release can be cut without tagging, while this is still being worked out. + workflow_dispatch: + inputs: + tag: + description: "Tag to release, e.g. v17.34.0" + required: true + +permissions: + contents: write + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Please + run: | + ./pleasew --version + echo "$HOME/.please" >> "$GITHUB_PATH" + - name: Build this repo's Please + # The released Please is not this one, and it hangs parsing //test/windows. Everything + # below uses the one we just built, the same two-step test.sh insists on. + run: ./pleasew build -p -v2 --profile ci //src:please + - name: Work out what we are releasing + id: version + run: | + tag="${{ github.event.inputs.tag }}" + [ -n "$tag" ] || tag="${GITHUB_REF#refs/tags/}" + version="$(cat VERSION)" + # A tag that disagrees with VERSION would publish assets nobody can find, since every + # consumer builds the filename out of the version rather than out of the tag. + [ "$tag" = "v$version" ] || { echo "tag $tag does not match VERSION $version" >&2; exit 1; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Build the releases + run: | + plz-out/bin/src/please build -p -v2 --profile ci --arch windows_amd64 //package:release_files + plz-out/bin/src/please build -p -v2 --profile ci //package:release_files + - name: Name the assets by platform + id: assets + run: | + version="${{ steps.version.outputs.version }}" + mkdir -p assets + for arch in windows_amd64 linux_amd64; do + for f in plz-out/pkg/$arch/*; do + [ -f "$f" ] || continue + # The same rename gen_release.py does: please_1.2.3.zip -> please_1.2.3_arch.zip. + base="$(basename "$f")" + cp "$f" "assets/${base/$version/${version}_$arch}" + done + done + ls -l assets + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --title "Please ${{ steps.version.outputs.version }}" \ + --notes "Built from this fork, which carries Windows support that is not upstream yet. + + Install on Windows: + + irm https://raw.githubusercontent.com/${{ github.repository }}/${{ steps.version.outputs.tag }}/tools/misc/get_plz.ps1 | iex + + See docs/design/windows/07-state-of-play.md for what works and what does not." \ + assets/* diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000000..afead1826c --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,183 @@ +# The only thing anywhere that runs Please on a real Windows machine. +# +# Everything else the port claims rests on Wine, and docs/design/windows/05-testing-strategy.md +# is explicit that Wine passing is evidence rather than proof. The classes it cannot show are +# the ones Please works hardest in: sharing violations on teardown, path length, case-insensitive +# collisions, symlink privileges. +# +# CircleCI cross-builds the same artifacts, but its workspaces are scoped to one CircleCI run +# and cannot be read from here, so this cross-builds its own on a Linux runner and hands them +# over. That duplication is the price of there being no Windows runner in CircleCI's config. +name: Windows + +on: + push: + branches: + - master + - wine + pull_request: + +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + cross-build: + name: cross-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Please + run: | + ./pleasew --version + echo "$HOME/.please" >> "$GITHUB_PATH" + - name: Build this repo's Please + # The released Please is not this one, and the difference is not cosmetic: it predates + # the parse-deadlock fix and hangs parsing //test/windows, silently and for ever. + # Everything below uses the one we just built, the same two-step test.sh insists on. + run: ./pleasew build -p -v2 --profile ci //src:please + - name: Cross-build the test bundle + run: plz-out/bin/src/please build -p -v2 --profile ci //test/windows:native_test_bundle + - name: Cross-build the release + # The same command CircleCI's build-windows job runs, so what the Windows job gets here + # is what a user would get. + run: plz-out/bin/src/please build -p -v2 --profile ci --arch windows_amd64 //package:release_files + - name: Extract the codelab plan + # Built here, on Linux, so the Windows job replays a plan that + # //test/windows/codelab_script/script:script_test has already checked against the same + # codelabs and the same sidecar. See test/windows/run_codelabs.ps1. + run: plz-out/bin/src/please build -p -v2 --profile ci //test/windows:codelab_plan + - name: Upload the codelab plan + uses: actions/upload-artifact@v4 + with: + name: windows-codelab-plan + path: plz-out/gen/test/windows/codelab_plan.json + retention-days: 7 + - name: Upload the test bundle + uses: actions/upload-artifact@v4 + with: + name: windows-test-bundle + path: plz-out/gen/test/windows/native_test_bundle + # The bundle's repo-root marker is a .plzconfig, and this action drops hidden files + # unless told not to. Without it the tests that look for a repo root find none and + # die, which nothing on the Linux side can catch - the Wine test runs the bundle + # directly, never through an artifact. + include-hidden-files: true + retention-days: 7 + - name: Upload the release + uses: actions/upload-artifact@v4 + with: + name: windows-release + path: plz-out/pkg/windows_amd64/please_*.zip + retention-days: 7 + - name: Upload the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: cross-build-logs + path: plz-out/log + retention-days: 7 + + test: + name: test + needs: cross-build + runs-on: windows-latest + # Blocking. It was advisory for exactly one run, to produce a failure list without a red + # check nobody had read yet; everything that run found is fixed. Anything genuinely left + # over belongs in test/windows/known_failures.txt with a reason, not behind a flag that + # makes the whole job ignorable. + steps: + - name: Keep Unix line endings + # The fixtures are compared byte for byte against output the bundled busybox produced, + # and busybox does not translate line endings. Has to precede the checkout. + run: git config --global core.autocrlf input + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: windows-test-bundle + path: ${{ runner.temp }}\bundle + - uses: actions/download-artifact@v4 + with: + name: windows-release + path: ${{ runner.temp }}\release + - name: Report the machine + # Both of these change what is reachable, both differ between runner images, and + # guessing either has already cost time. Print them rather than assuming. + run: | + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -EA SilentlyContinue) | + Select-Object -ExpandProperty LongPathsEnabled -EA SilentlyContinue | + ForEach-Object { "LongPathsEnabled=$_" } | Tee-Object -Append $env:GITHUB_STEP_SUMMARY + whoami /priv | Select-String SeCreateSymbolicLinkPrivilege | + Tee-Object -Append $env:GITHUB_STEP_SUMMARY + - name: Run the cross-built unit tests + run: | + ./test/windows/run_native_tests.ps1 ` + -Bundle "$env:RUNNER_TEMP\bundle" ` + -Logs "$env:RUNNER_TEMP\logs" ` + -KnownFailures test/windows/known_failures.txt + - name: Build a repo with the release + if: ${{ !cancelled() }} + run: | + ./test/windows/run_native_probes.ps1 ` + -Release "$env:RUNNER_TEMP\release" ` + -Logs "$env:RUNNER_TEMP\logs" + - name: Upload the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: windows-test-logs + path: ${{ runner.temp }}\logs + retention-days: 7 + + codelabs: + name: codelabs + needs: cross-build + runs-on: windows-latest + # A job of its own rather than a step in test. It needs the release but not the bundle, it + # runs for far longer - eight codelabs, plugin downloads, a Go toolchain - and a hung codelab + # should not eat the unit tests' time or delay the signal people actually read. + timeout-minutes: 90 + # Blocking from its first run, for the reason the test job gives. What the codelabs cannot do + # on Windows is expected, and the mechanism for expected already exists: every such step is + # in test/windows/codelab_known_failures.txt with a reason. There is no advisory mode to + # forget to take out. + steps: + - name: Keep Unix line endings + # The runner script and the failures list come from the checkout. Has to precede it. + run: git config --global core.autocrlf input + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: windows-release + path: ${{ runner.temp }}\release + - uses: actions/download-artifact@v4 + with: + name: windows-codelab-plan + path: ${{ runner.temp }}\plan + - name: Report the toolchains + # The codelabs assume Go, Python, git, Docker and kubectl, and which of those exist - and + # for Docker, which kind of containers it runs - decides what is skipped. Print it rather + # than inferring it from the results. + run: | + "pwsh = $($PSVersionTable.PSVersion)" | Tee-Object -Append $env:GITHUB_STEP_SUMMARY + foreach ($t in 'go', 'python', 'git', 'docker', 'kubectl') { + $c = Get-Command $t -EA SilentlyContinue + "$t = $(if ($c) { $c.Source } else { 'not installed' })" | Tee-Object -Append $env:GITHUB_STEP_SUMMARY + } + if (Get-Command docker -EA SilentlyContinue) { + "docker OSType = $(docker info --format '{{.OSType}}' 2>$null)" | Tee-Object -Append $env:GITHUB_STEP_SUMMARY + } + - name: Run the codelabs + run: | + ./test/windows/run_codelabs.ps1 ` + -Plan "$env:RUNNER_TEMP\plan\codelab_plan.json" ` + -Release "$env:RUNNER_TEMP\release" ` + -Logs "$env:RUNNER_TEMP\logs" ` + -KnownFailures test/windows/codelab_known_failures.txt + - name: Upload the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: windows-codelab-logs + path: ${{ runner.temp }}\logs + retention-days: 7 diff --git a/.plzconfig b/.plzconfig index 3c69865cf8..433b91bea1 100644 --- a/.plzconfig +++ b/.plzconfig @@ -105,6 +105,7 @@ accept = Apache-2.0 accept = Apache License, Version 2.0 accept = The Apache Software License, Version 2.0 accept = PSF +accept = PSF-2.0 accept = ASL accept = MPL-2.0 accept = LGPL @@ -112,6 +113,10 @@ accept = Artistic License accept = ISC # Not really a licence, but Bazel projects commonly describe things this way. accept = notice +# Only for the busybox we ship in the Windows release. Please neither links it nor derives +# from it - it is execed as the shell - so the two are separately distributed works, and the +# release records the licence and where to get the source. +accept = GPL-2.0 [remote] url = diff --git a/.plzconfig_windows_amd64 b/.plzconfig_windows_amd64 new file mode 100644 index 0000000000..9b0646e79c --- /dev/null +++ b/.plzconfig_windows_amd64 @@ -0,0 +1,19 @@ +; Per-architecture config for windows_amd64, layered on .plzconfig by +; state.ForArch(). See docs/design/windows/ for the design notes behind these. + +[Plugin "go"] +; go-flags treats '/' as its option delimiter and ':' as its name/argument +; delimiter on Windows, which collides with Please's own label syntax: +; //pkg:target parses as option /pkg with argument target. The forceposix tag +; disables that behaviour. Without it every command taking a build label is +; broken. See docs/design/windows/00-overview.md, decision D5. +BuildTags = forceposix + +[build] +; Windows has no extended attributes; fall back to the sidecar-file mechanism. +xattrs = false + +[sandbox] +; No mount/network namespace equivalent yet. See M7. +build = false +test = false diff --git a/BUILD b/BUILD index 2cb4d48d9b..cf93654e8c 100644 --- a/BUILD +++ b/BUILD @@ -29,7 +29,10 @@ filegroup( filegroup( name = "pleasew", - srcs = ["pleasew"], + srcs = [ + "pleasew", + "pleasew.ps1", + ], binary = True, visibility = ["//src/assets/..."], ) diff --git a/ChangeLog b/ChangeLog index 888ed17376..5dc9c712bc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,25 @@ +Version 18.0.0 +-------------- + * Native Windows support on amd64. `plz` runs, builds and tests on Windows, + with busybox bundled as the build shell so a fresh install needs no + configuration. Go, C++ and Python targets all build; `sh_binary` produces a + `.cmd`, since Windows has no shebang mechanism. + * Windows installer at https://get.please.build/get_plz.ps1, and a + `pleasew.ps1` counterpart to `pleasew`. + * Releases for `windows_amd64` are a `.zip`, with a `plz.cmd` shim in place of + the `plz` symlink, which Windows will not create without Developer Mode. + * `plz clean` no longer fails on Windows. Please held its own log file open + inside the directory it was deleting. + * `plz` no longer hangs when run outside a repo on Windows. The walk towards + the filesystem root never terminated at a drive letter. + * `link:` labels fall back to copying where Windows refuses to create a + symlink, rather than silently doing nothing. + * `remote_file` with a `file://` URL is correctly refused when it points + inside the repo on Windows; the check compared path separators that never + matched. + * No build sandbox on Windows: it is built on Linux namespaces, and there is + no equivalent. + Version 17.33.0 --------------- * Fix macOS tmpdir cleanup race caused by system daemons creating `~/Library` (#3515) diff --git a/README.md b/README.md index 1850deb1cb..a6acfe7d36 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ nearly any aspect of your build process. See [please.build](https://please.build) for more information. -Currently Linux (tested on Ubuntu), macOS and FreeBSD are actively supported. +Currently Linux (tested on Ubuntu), macOS, FreeBSD and Windows (amd64) are actively supported. If you're a fan of Please, don't forget to add yourself to the [adopters](https://github.com/thought-machine/please/blob/master/ADOPTERS.md) diff --git a/VERSION b/VERSION index 1d6c72b5c7..7eae4e2e91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.33.0 +18.0.0 diff --git a/docs/codelabs/BUILD b/docs/codelabs/BUILD index c6c2985c20..72431714ca 100644 --- a/docs/codelabs/BUILD +++ b/docs/codelabs/BUILD @@ -31,3 +31,13 @@ genrule( tools = [":codelab_templator"], visibility = ["//docs/..."], ) + +# The codelab sources, for //test/windows/codelab_script, which checks they run on Windows. +filegroup( + name = "codelab_md", + srcs = glob(["*.md"]), + visibility = [ + "//docs/...", + "//test/windows/...", + ], +) diff --git a/docs/config.html b/docs/config.html index c7f8cb8b92..5645dc7094 100644 --- a/docs/config.html +++ b/docs/config.html @@ -816,6 +816,20 @@

ParallelDownloads {{ index .ConfigHelpText "build.paralleldownloads" }}

+
  • +
    +

    Shell

    + +

    {{ index .ConfigHelpText "build.shell" }}

    +
    +
  • +
  • +
    +

    ShellArgs (repeatable)

    + +

    {{ index .ConfigHelpText "build.shellargs" }}

    +
    +
  • diff --git a/docs/design/windows/00-overview.md b/docs/design/windows/00-overview.md new file mode 100644 index 0000000000..34904348f2 --- /dev/null +++ b/docs/design/windows/00-overview.md @@ -0,0 +1,182 @@ +# Windows Port — Overview + +Status: **Draft** · Owner: _unassigned_ · Last updated: 2026-09-10 + +This directory holds the engineering design documents for adding a Windows port to Please. +They are working documents for contributors, not user-facing documentation — the docs site +build (`docs/BUILD`) only globs `milestones/*.html` and does not pick this directory up. + +**Starting cold? Read `07-state-of-play.md`.** It is the short version: what works, where the +five repos are, what to pick up next, and the mistakes that have already cost time once each. +Then this file for the why, and `06-milestones.md` for the per-milestone detail. + +## Why + +Please ships binaries for `linux_amd64`, `linux_arm64`, `darwin_amd64`, `darwin_arm64` and +`freebsd_amd64`. There is no Windows build. `pleasew` bails out with *"Please does not +support the %s operating system"*, and `src/cli/winch_windows.go` is the only Windows-aware +file in 272 Go source files. + +The 15.9.1 milestone note said *"We're scoping out support for Windows"*. This is that +scoping, turned into a plan. + +## Goal + +`plz.exe` on Windows, at **full feature parity** with the Linux build, with **building C++ +projects as the primary driving use case**. + +Parity means sandboxing, remote execution and all four language plugins eventually work. +It does not mean they all arrive at once: the milestone sequence deliberately front-loads a +working C++ vertical slice, then fills in the rest. + +## Constraint that shapes everything + +**Development and CI stay on Linux.** The binary is cross-compiled. Real Windows testing is +a later stage (M9). + +This is workable because of two independent cross-compilation axes. Conflating them is the +most likely way to lose a week, so they get separate names throughout these documents. + +### Axis 1 — cross-building `plz.exe` + +`GOOS=windows go build`, driven from a Linux host by: + +```bash +plz build --arch windows_amd64 //package:release_files +``` + +Produces the Please binary we ship. Modelled on the existing FreeBSD release flow, which is +already a Linux-hosted cross build (`.circleci/config.yml`, the `build-freebsd` job). + +### Axis 2 — cross-building C++ *for* Windows + +`plz --arch windows_amd64` with `.plzconfig_windows_amd64` pointing `cpptool` at +`x86_64-w64-mingw32-g++`. Produces `.exe`/`.dll`/`.a` artifacts. + +The important property: **this exercises every cc-rules Windows codepath while plz itself +runs on Linux.** Roughly 80% of the C++ work is verifiable before any Windows machine is +involved. + +Layer Wine on top (M6) and `plz.exe` itself becomes testable on Linux too. See +`05-testing-strategy.md`. + +## Decisions + +Each decision has a fuller ADR in the linked document. D1–D4 were taken during planning; +D5 came out of the M0 investigation. + +### D1 — MinGW-w64 GCC first, MSVC/clang-cl later + +See `03-cc-toolchain.md`. + +MinGW reuses the existing GNU-driver flag logic (`-Wl,--start-group`, `--whole-archive`, +`-Wl,--gc-sections`) and the existing `please_cc` GCC matcher essentially unchanged. +Critically, `x86_64-w64-mingw32-g++` runs **on Linux**, so Axis 2 works from day one. + +MSVC would require a new flag dialect (`/c`, `/Fo`, `/EHsc`, `.obj`/`.lib`), `vcvarsall` +environment discovery, *and* a Windows host — three unknowns at once. It remains the +eventual target for most real-world Windows C++ projects, and `please_cc` is the designed +extension point for it. + +### D2 — Bundle a POSIX shell (busybox-w64) in the Windows release + +See `02-shell-and-build-actions.md`. + +Build actions run through `bash --noprofile --norc -e -u -o pipefail -c` +(`src/process/process.go`), and the cc rules emit genuine shell pipelines — `find | sort | +sed | tr`, backticks, `&&`. Bundling busybox pins the behaviour, requires nothing installed +on the user's machine, and avoids MSYS2's `/c/foo` ⇄ `C:\foo` path translation. + +The alternative considered and rejected: rewriting the cc rules to be shell-free. That is +architecturally cleaner and remains a good idea for its own sake, but it is a large change +in a second repo and would block the Windows port on it. + +### D3 — Full feature parity is the destination + +Sandboxing (M7), remote execution and the go/python/shell plugins (M8) are real milestones, +not a dropped backlog. The OS abstraction layer built in M1 is designed so they have +somewhere to land — in particular, the Job Object machinery introduced for process control +is also what a Windows sandbox will be built on. + +### D4 — Two repos, one programme + +The C/C++ rules are not in this repo. `plugins/BUILD` pins `please-build/cc-rules` at +`v0.7.3`, fetched as a plugin subrepo. + +- **Workstream A** — `thought-machine/please`: the core port, the OS abstraction layer, the + release pipeline. +- **Workstream B** — `please-build/cc-rules`: `please_cc`, output extensions, the MinGW + flag review. + +During development, point `plugins/BUILD` at a fork or branch revision. Upstream to +`please-build/cc-rules` as the final step of M5. + +### D5 — Build every go-flags binary with `-tags forceposix` + +Discovered by running the binary, not by reading the source. `go-flags` uses `/` as its option +delimiter and `:` as its name/argument delimiter on Windows, which collides with Please's +**entire label syntax**: `//pkg:target` parses as option `/pkg` with argument `target`, and +`//...` is rejected as an unknown flag. + +The library guards that file with `// +build !forceposix`, so the fix is a build tag. Verified: +label parsing works completely with it, and is completely broken without it. + +**It is set as config, not per-target.** `go_binary` has no `tags` parameter — the go plugin +takes build tags from `CONFIG.GO.BUILD_TAGS`, which also feeds the stdlib, every `go_repo` +and the source filter. So it belongs in `.plzconfig_windows_amd64`: + +```ini +[Plugin "go"] +BuildTags = forceposix +``` + +Scoping it to the arch config means it applies to every go-flags binary built for Windows — +`//src:please`, `//tools/please_shim` — without affecting any other platform. + +This must be recorded as a decision rather than a code comment, because it is invisible in +Please's own source and will silently regress if the tag is ever dropped. + +See R1 in `appendix-baseline-errors.md`. + +## Non-goals + +Explicitly out of scope for this programme: + +- **MSVC support.** Designed for (see `03-cc-toolchain.md`) but not built. +- **32-bit Windows.** `windows_amd64` only. `windows_arm64` is plausible later; nothing in + the design precludes it. +- **`pkg-config` on Windows.** The codepath stays, but it is documented as unsupported. + Users set flags explicitly. +- **Windows Containers for sandboxing.** M7 accepts the tmp-dir isolation Please already + does and documents the gap rather than taking on that dependency. +- **Native Windows as a development platform.** M9 adds native CI; the day-to-day loop + stays on Linux. + +## Document index + +| Document | Contents | +|---|---| +| `00-overview.md` | This file. Charter, decisions, axes, non-goals. | +| `01-os-abstraction.md` | The `_windows.go` convention, `ExecReplace`, Job Objects, file locking, xattrs. | +| `02-shell-and-build-actions.md` | ADR for D2. busybox applet audit. The path-format rule. | +| `03-cc-toolchain.md` | ADR for D1. MinGW flag matrix. Output extensions. The MSVC extension point. | +| `04-release-and-ci.md` | Cross-build and release pipeline, modelled on FreeBSD. | +| `05-testing-strategy.md` | MinGW for Axis 2, Wine for `plz.exe`, and what Wine misses. | +| `06-milestones.md` | The living tracker. Status, exit criteria, owners. | +| `07-state-of-play.md` | Where the port actually is, what to pick up next, and the standing traps. | +| `appendix-baseline-errors.md` | **Measured** M0 results: compile blockers, runtime findings, what already works. | +| `probe/` | Throwaway M0 artifacts, incl. `m1-skeleton.patch`. Not implementations. | + +## Process + +Per `CONTRIBUTING.md`: raise a GitHub issue for each milestone **before** writing code, and +keep PRs small and single-purpose — no refactors mixed with features. + +Two repo-specific hazards worth repeating here: + +- **Hash stability.** Please's cache is content-hash based over rule definition, config, + sources and secrets. Any change to command generation, environment variables or config + defaults changes target hashes and invalidates every user's cache. Confirm `plz hash + //...` is unchanged on Linux before merging anything in M1–M3. +- **Brittle e2e tests.** The tests in `test/` assert on exact output text. Expect to update + `.txt` golden files; treat any *unexpected* change there as a real regression. diff --git a/docs/design/windows/01-os-abstraction.md b/docs/design/windows/01-os-abstraction.md new file mode 100644 index 0000000000..eda68cef9d --- /dev/null +++ b/docs/design/windows/01-os-abstraction.md @@ -0,0 +1,302 @@ +# OS Abstraction Layer + +Status: **Draft** · Milestones: M1, M2 · Last updated: 2026-09-10 + +How platform-specific code is organised, and the design of each abstraction the Windows +port introduces. See `00-overview.md` for the programme charter. + +## The convention + +Please already has the pattern; it is just barely used. `src/cli/winch_windows.go` plus +`src/cli/winch_other.go` (`//go:build !windows`) is the template. Follow it: + +``` +foo_windows.go // no build tag needed — the filename suffix is the constraint +foo_other.go // //go:build !windows +``` + +Two rules that are easy to get wrong here: + +1. **The filename suffix is itself a build constraint.** `exec_linux.go` has no `//go:build` + line and does not need one. Adding a redundant one is harmless; omitting the constraint + on the `_other.go` sibling is not. +2. **`plz` lists `srcs` explicitly in BUILD files** (see `src/process/BUILD`). New files + must be added there. `plz puku sync` handles `third_party/go`, not first-party srcs. + +### Beware the `!linux` files + +`src/process/exec_other.go` and `src/sandbox/sandbox_other.go` are constrained `!linux`, +which means **they are selected on Windows**. One of them compiles there and one does not: + +- `sandbox_other.go` degrades to a plain `exec.Command(...).Run()` and compiles fine. Not a + blocker. +- `exec_other.go` sets `SysProcAttr{Setpgid, Foreground}` — fields that do not exist in + Windows' `SysProcAttr`. Narrow its constraint to `!linux && !windows`. + +## Inventory + +**Measured, not predicted** — see `appendix-baseline-errors.md` for method and evidence. +The original source survey over-stated this considerably. + +### Blocks compilation — the complete set + +Four layers, five sites, four packages. Each layer is only visible once the previous one is +fixed, because `src/process` is a dependency of nearly everything. + +| Layer | Site | Problem | Design | +|---|---|---|---| +| 1 | `src/process/exec_other.go:17,18` | `SysProcAttr{Setpgid, Foreground}` | New `exec_windows.go`; narrow `!linux` → `!linux && !windows` | +| 1 | `src/process/process.go:206` | `syscall.Kill(-pid, …)` | Job Objects — see below | +| 2 | `src/core/lock.go` ×10 | `syscall.Flock`, `LOCK_SH/EX/UN/NB` | `LockFileEx` — see below | +| 3 | `src/clean/clean.go:96` | `syscall.ForkExec` | Detached `exec.Command` | +| 3 | `src/output/shell_output.go:467` | `cmd.SysProcAttr.Setpgid` | **Abstraction leak** — see below | + +After these, every package compiles and `./src` links to a valid PE32+ binary. + +### The abstraction leak + +`src/output/shell_output.go:467` reaches into the process executor's platform-specific +attributes from *outside* `src/process`: + +```go +cmd := state.ProcessExecutor.ExecCommand(...) +// TODO(jpoole): Read the docs. Attaching stdin and out doesn't seem to work with this. +cmd.SysProcAttr.Setpgid = false +``` + +The fix is not a build tag at the call site — it is to expose the *intent* from `src/process` +(`process.ClearProcessGroup(cmd)`, or a parameter on `ExecCommand`) so the platform detail +stays in one package. Audit for other instances while doing M1. + +### Does NOT block compilation (corrections) + +Three predictions were wrong, all from the same mistake: assuming "Unix-only API" means "does +not compile on Windows". Go's `syscall` package ships Windows stubs. + +| Site | Reality | +|---|---| +| `syscall.Exec` ×5 (`src/please.go`, `src/run`, `src/tool`, `src/update`, `tools/please_shim`) | Defined in `syscall/exec_windows.go`, returns `EWINDOWS`. **Compiles; fails at runtime.** | +| `syscall.Chdir` (`src/run/run_step.go`) | Defined on Windows | +| `SIGHUP`/`SIGQUIT`/`SIGABRT` (`src/cli/process.go`) | All defined in `syscall/types_windows.go` | +| `github.com/pkg/xattr` (`src/fs/attr.go`) | Ships `xattr_unsupported.go`; returns `ENOTSUP` | + +**`syscall.Exec` being a silent runtime failure is worse than a compile error**, not better. +`plz run`, `plz tool`, `plz update`, `plz op` and the shim will build, ship, and then fail +with an opaque *"not supported by windows"*. The compiler cannot drive this work — it needs +tests. Likewise, narrowing the signal set and defaulting `Build.Xattrs = false` are +*correctness* changes with no build-time signal. + +### Compiles, behaves wrong + +Covered in M2. `PATH` split on literal `":"` (7 sites in `src/core/config.go` and +`src/core/utils.go`, plus `src/remote/action.go`); `src/fs/home.go` reading `$HOME` +directly; `/etc/please/plzconfig` and `DefaultPath`; `const SandboxDir = "/tmp/plz_sandbox"`; +the executable-bit model; `os.Symlink` privileges; `RemoveAll`'s chmod-to-force-delete; +hardcoded `sh -c` in `src/cache/cmd_cache.go`; `HOME=tmpDir` in `src/core/build_env.go`. + +One of these was **confirmed as a hard startup blocker**, not a cosmetic issue: +`src/cli/logging.go:64` uses `path.Dir(logFile)` where it needs `filepath.Dir`, so `plz` +cannot create its log directory and dies before doing anything. Fix it early in M1, not in M2 +— nothing can be tested under Wine until it is fixed. + +## Design: process control via Job Objects + +`src/process` is the highest-leverage package — every build action, test and `plz run` +funnels through it. It is also where Unix and Windows differ most. + +Three separate Unix mechanisms collapse into one Windows primitive: + +| Unix | Where | Purpose | +|---|---|---| +| `SysProcAttr{Setpgid: true}` | `exec_other.go`, `exec_linux.go` | Group the child and its descendants | +| `Pdeathsig: syscall.SIGHUP` | `exec_linux.go` | Kill orphans if plz dies | +| `syscall.Kill(-pid, sig)` | `process.go` | Signal the whole group | + +**On Windows all three are a Job Object** created with +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Assign the child to the job at creation; every +descendant inherits membership; closing the handle (including on abnormal plz exit) kills +the tree. `TerminateJobObject` is the group kill. + +### Graceful-then-forceful termination + +`killProcess` currently sends `SIGTERM`, waits 30ms, then `SIGKILL`, waits 1s. There is a +deliberate comment in `ExecWithTimeout` explaining why `exec.CommandContext` is *not* used: +it only sends `SIGKILL`, which children cannot handle. **Preserve that intent.** + +The Windows equivalent: + +1. `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pgid)` — the closest thing to `SIGTERM`. + Requires the child to have been created with `CREATE_NEW_PROCESS_GROUP`. +2. Wait the same 30ms. +3. `TerminateJobObject` — the `SIGKILL` equivalent. + +Ctrl-Break delivery is unreliable for GUI subsystem processes and for children that have +detached from the console. Treat step 1 as best-effort; step 3 is the guarantee. + +### Where this lands + +``` +src/process/exec_windows.go // ExecCommand: create job, CREATE_NEW_PROCESS_GROUP +src/process/kill_windows.go // killProcess, sendSignal +src/process/kill_unix.go // //go:build !windows — the existing signal path +``` + +`process.go` keeps the timing policy and the executor bookkeeping; only the two primitives +(`sendSignal`, group kill) move behind the build tag. + +## Design: `process.ExecReplace` + +`syscall.Exec` replaces the current process image. Windows has no equivalent — Go's +`syscall/exec_windows.go` defines it as a stub returning `EWINDOWS`. + +**None of the six call sites break compilation.** They build, ship, and fail at runtime with +an opaque error. That makes this the highest-risk item in M1: there is no compiler signal, so +it must be driven by tests. + +Introduce one helper rather than six ad-hoc fixes: + +```go +// ExecReplace replaces the current process with the given command where the OS supports +// it, and otherwise runs it as a child and exits with its status. It does not return on +// success. +func ExecReplace(argv []string, env []string) error +``` + +- **Unix** (`exec_replace_unix.go`): `syscall.Exec(argv[0], argv, env)`. Behaviour unchanged. +- **Windows** (`exec_replace_windows.go`): spawn, wait, `os.Exit(child.ExitCode())`. + +### The behavioural difference, and why it matters + +On Windows `plz run` and `plz tool` become a *parent process that outlives the child*. That +is not a transparent substitution, and three things follow: + +1. **Signal forwarding must be explicit.** Ctrl-C in the console reaches both processes; the + parent must not exit before the child has finished cleaning up, or the user sees plz's + exit code instead of the program's. +2. **The parent must not hold the repo lock** while waiting. `src/core/lock.go` writes the + PID into `plz-out/.lock`; a parent blocked in `Wait()` holding an exclusive lock + deadlocks any nested plz invocation. Release before spawning. +3. **Exit codes must round-trip exactly.** `ExitError.ExitCode()` on Windows returns the + raw process exit code, which for a crashing program is an `NTSTATUS` (e.g. + `0xC0000005`). Do not truncate it to 8 bits. + +Document all three in the code comment on `ExecReplace`, not just here. + +### Special case: `tools/please_shim` + +The shim's entire design is exec-replace: resolve `~/.please`, check the version, hand off. +On Windows it additionally needs `.exe`-aware path construction — +`filepath.Join(Location, "please")` must become `please.exe`. It is a separate binary with +its own `main`, so it needs its own copy of the helper or a shared package. + +## Design: file locking + +`src/core/lock.go` opens `plz-out/.lock` and holds an advisory `flock` on it, reusing one +file descriptor so the lock mode can be upgraded/downgraded in place. The file header says +so explicitly: *"The logic below relies heavily on flock (advisory locks)."* + +Split into: + +``` +src/core/lock_unix.go // //go:build !windows — syscall.Flock, unchanged +src/core/lock_windows.go // LockFileEx / UnlockFileEx +``` + +Mapping: + +| Unix | Windows | +|---|---| +| `LOCK_SH` | `LockFileEx` with no flags | +| `LOCK_EX` | `LockFileEx` with `LOCKFILE_EXCLUSIVE_LOCK` | +| `LOCK_NB` | `LOCKFILE_FAIL_IMMEDIATELY` | +| `LOCK_UN` | `UnlockFileEx` | + +Two semantic differences to handle: + +- **`LockFileEx` locks a byte range, not the file.** Lock `[0, 1)` consistently; the code + writes a PID into the file, so do not lock a range that the write touches, or use a + distinct offset well past any content. +- **Mode upgrade is not atomic.** `flock` can atomically convert shared → exclusive on the + same fd. On Windows you must unlock and relock, which opens a race window. The existing + callers (`AcquireSharedRepoLock` / `AcquireExclusiveRepoLock`) acquire once at startup, so + this is tolerable — but assert it rather than assuming it, and cover it in + `lock_test.go`. + +This is the most self-contained piece of M1 and the best place to start. + +## Design: signal handling + +`src/cli/process.go` registers `SIGHUP`, `SIGINT`, `SIGQUIT`, `SIGABRT`, `SIGTERM` and exits +with `128 + signum`. + +On Windows, Go's `signal` package delivers only `os.Interrupt` (Ctrl-C) and +`syscall.SIGTERM` (synthesised). The rest are unusable. Narrow the set behind a build tag, +and note that the `128 + signum` convention is a shell idiom with no meaning on Windows — +exit `1` instead. The `AtExit` handler machinery itself is portable and stays shared. + +## Design: xattrs + +`src/fs/attr.go` uses `github.com/pkg/xattr` to store content hashes as extended attributes, +consumed by `src/test/test_step.go` (`user.plz_test`) and `src/build`. + +**A fallback already exists and is well-factored.** `RecordAttr` takes an `xattrsEnabled +bool` and delegates to `RecordAttrFile` (a sidecar file) when false. The config knob is +`config.Build.Xattrs`. + +So the work is small: + +1. Default `Build.Xattrs` to `false` on Windows. +2. Confirm `pkg/xattr` compiles for `GOOS=windows`. It ships a stub returning `ENOTSUP`, in + which case no build tag is needed at all and step 1 is sufficient. **Verify this in M0 + rather than assuming it** — if the stub is absent, split `attr.go` into + `attr_unix.go`/`attr_windows.go`. + +Note the chmod-to-set-xattr dance in `RecordAttr` (chmod `|0200`, set, restore) becomes +dead code on Windows, which is fine — it is behind the `xattrsEnabled` branch. + +## Design: the `.exe` model + +The question with the widest blast radius, and the one most likely to be over-engineered. + +Today, executability is a mode bit: `core.BuildTarget.OutMode()` returns `0555` for binary +targets and `0444` otherwise, applied by `src/build/build_step.go`. `src/fs/executable.go` +checks `(mode & 0111) == 0`. Nothing anywhere handles `.exe`. + +**Recommendation: do not change the core model.** + +`OutMode()` stays as-is — the mode bits are simply ignored by Windows, which is harmless. +The `.exe` suffix becomes a *rule-level* concern, handled in cc-rules (M5) and the go +plugin (M8) where the output name is chosen. This keeps the `plz-out` layout identical +across platforms and avoids threading a platform flag through `BuildTarget`. + +Core only needs `.exe` awareness in the three places where it *looks up* an executable +rather than declaring one: + +- `src/fs/executable.go` — the `mode & 0111` check and the `$PATH` search in `Executable()`. + Use `PATHEXT` on Windows. +- `src/run/run_step.go` — `!strings.Contains(args[0], "/")` decides "is this a bare command + name". Needs to consider `\` too. +- `tools/please_shim/main.go` — `filepath.Join(Location, "please")`. + +If this turns out wrong — specifically, if `plz run` on a `cc_binary` cannot find its output +without core knowing about `.exe` — revisit before M6 rather than patching around it. + +## Reuse rather than reinvent + +Already in the tree, correct, and currently under-used: + +- **`src/fs/executable.go` `splitPathList`** — a correct `os.PathListSeparator`-based split, + used only by the FreeBSD `Executable()` fallback. Promote it to exported + `fs.SplitPathList`/`fs.JoinPathList` and use it for all 7 raw `":"` splits in M2. Do not + write a new one. +- **`rules/misc_rules.build_defs` `is_platform`** — the platform conditional for BUILD + files. `src/BUILD.plz` uses it for the Linux-only `ldd` static-link assertion; that is the + pattern to copy for Windows-conditional packaging. +- **`src/core/state.go` `ForArch`** — per-arch config layering (`.plzconfig__`). + No changes needed; `.plzconfig_windows_amd64` slots straight in. +- **`config.Build.Xattrs`** — the xattr fallback, above. +- **`Remote.Shell`** (`src/core/config.go`, `src/remote/remote.go`) — the only configurable + shell in the codebase today, and the precedent for the local `[build] Shell` knob in + `02-shell-and-build-actions.md`. +- **`cli.Arch`** (`src/cli/flags.go`) — generic `OS_ARCH` parsing. `windows_amd64` parses + today with no code change. diff --git a/docs/design/windows/02-shell-and-build-actions.md b/docs/design/windows/02-shell-and-build-actions.md new file mode 100644 index 0000000000..54807a0e54 --- /dev/null +++ b/docs/design/windows/02-shell-and-build-actions.md @@ -0,0 +1,237 @@ +# Build Actions and the Bundled Shell + +Status: **Draft** · Milestone: M3 · Last updated: 2026-09-10 + +ADR for decision **D2**: ship a POSIX shell inside the Windows release rather than depending +on one being installed. See `00-overview.md` for the decision summary. + +## The problem + +Every build action, test and `plz run --cmd` is a **shell string**, not an argv. It is +executed by `src/process/process.go`: + +```go +func BashCommand(binary, command string, exitOnError bool) []string { + if exitOnError { + return []string{binary, "--noprofile", "--norc", "-e", "-u", "-o", "pipefail", "-c", command} + } + return []string{binary, "--noprofile", "--norc", "-u", "-o", "pipefail", "-c", command} +} +``` + +`binary` is the literal string `"bash"` at every local call site +(`process.go`, `src/run/run_step.go`). Only the *remote* execution path is configurable +(`Remote.Shell`, `src/core/config.go`). + +The command strings are not incidentally shell-shaped — they are genuinely shell. The +built-in rules use `&&`, `>`, `echo`, `mkdir`, `cp -r`, `mv`, `xz`. The cc rules go much +further: backticks, `find`, `sort`, `sed`, `tr`, and `; R=$?; …; exit $R`. + +The worst single line, from `cc-rules` `build_defs/cc.build_defs` (`_binary_build_flags`): + +```sh +find . -name '*.o' -or -name '*.a' | sort \ + | sed -e 's/\(.*\)/"-Wl,-force_load","\1"/' | tr '\n' , | sed -e 's/.$//' +``` + +Windows has none of this. + +## Options considered + +| Option | Verdict | +|---|---| +| **Require MSYS2 / Git Bash on PATH** | Rejected. Every user needs an extra install, and MSYS2's `/c/foo` ⇄ `C:\foo` path translation is applied heuristically to arguments that look like paths — which silently mangles compiler flags. | +| **Rewrite cc rules to be shell-free** | Deferred, not rejected. Moving `find`/`sort`/whole-archive assembly into `please_cc` is architecturally cleaner and would benefit every platform. But it is a large change in a second repo, and blocking the Windows port on it inverts the priorities. Revisit after M6. | +| **Bundle busybox-w64** | **Chosen.** Pins behaviour, needs nothing installed, no path translation. Costs one vendored binary (~700KB) in the release. | + +## Verification results + +Two rounds: Linux BusyBox 1.36.1 first, then the real **busybox-w64 1.38.0-FRP-6075** +(, SHA-256 +`07bb1e5b095b00d68a695481f9240879f33c5724b40aa2308f999d54ed78f075`) under Wine 9.0. + +**The two rounds disagreed, and the w64 result is the one that counts.** The caution +originally written here — *"re-run every check against the actual busybox.exe"* — was +load-bearing. + +### busybox-w64 ships a `bash` applet + +Better than expected. Its applet list includes `bash` as well as `sh` and `ash`, so Please's +hardcoded `"bash"` resolves if `busybox.exe` is copied or hardlinked to `bash.exe`. No +indirection needed for a first cut. + +### Shell flags — `--noprofile`/`--norc` are rejected + +Linux busybox tolerated bash's full flag set. **busybox-w64 does not:** + +```console +$ wine bash.exe --noprofile --norc -e -u -o pipefail -c 'echo ok' +bash: bad option '--noprofile' +``` + +Everything else is accepted, with semantics identical to bash: + +```console +$ wine bash.exe -e -u -o pipefail -c 'false; echo REACHED' # exit=1, not reached +$ wine bash.exe -e -u -o pipefail -c 'echo "$NOPE"' # NOPE: parameter not set, exit=2 +$ wine bash.exe -u -o pipefail -c 'false | true' # exit=1 +``` + +**Consequence: `ShellArgs` is mandatory, not a hedge.** `BashCommand` must drop +`--noprofile --norc` on Windows. Those two flags exist to stop bash sourcing user rc files; +busybox's shell has no rc files to source, so dropping them loses no hermeticity. + +### The cc-rules pipeline works verbatim + +Under Wine, through `plz.exe`, as a real build action: + +```python +genrule( + name = "findpipe", + outs = ["found.txt"], + cmd = "mkdir -p d/e && touch d/a.o d/e/b.a && find . -name '*.o' -or -name '*.a' | sort | tr '\\n' ',' > $OUT", +) +``` + +produces `./d/a.o,./d/e/b.a,`. This is the construct `_binary_build_flags` depends on, and it +is the strongest evidence for D2. + +`cat $SRCS | sort > $OUT` also produces correctly sorted output across two source files. + +### Applet coverage + +Present in busybox-w64 and used by Please's rules: `sh`, `ash`, `bash`, `find`, `sort`, `sed`, +`tr`, `cat`, `cp`, `mv`, `rm`, `mkdir`, `echo`, `printf`, `test`, `dirname`, `basename`, +`xargs`, `cut`, `which`, `env`, `tar`, `gzip`, `unzip`, `head`, `tail`, `wc`, `tee`, `touch`, +`ln`, `readlink`, `realpath`, `grep`, `awk`, `flock`, `install`, `make`. + +**Gaps:** + +1. **`pkg-config` is absent.** No applet, no shim. Documented as unsupported on Windows — + see `03-cc-toolchain.md`. +2. **`zip` is absent** (only `unzip`). Relevant to the M4 `.zip` release target, which is + produced on Linux, so not a problem. +3. **`xz` compression.** Linux busybox `xz` is decompress-only (`xz -zc` → `invalid option + -- 'z'`). `rules/misc_rules.build_defs` uses `xz -zc -T 0 $SRCS > "$OUT"` for + `tarball(xzip = True)`. Release artifacts are produced on Linux so this is not on the + user's critical path — gate the rule on `is_platform(os = "linux")`. Re-verify against + busybox-w64, whose applet list does include `xz`. + +## Design + +### Config + +Add to `[build]`, mirroring the existing `Remote.Shell`: + +```ini +[build] +Shell = bash ; unix default +ShellArgs = --noprofile ; repeatable +ShellArgs = --norc +ShellArgs = -u +ShellArgs = -o +ShellArgs = pipefail +``` + +On Windows the default resolves to the bundled `bash.exe` (busybox) with +`ShellArgs = -u -o pipefail` — i.e. the same set **minus `--noprofile --norc`**, which +busybox-w64 rejects. The `-e` flag stays conditional on `target.ShouldExitOnError()` and is +appended by `BashCommand`, not configured. + +Verified working shape (from the probe): + +```go +func BashCommand(binary, command string, exitOnError bool) []string { + argv := append([]string{binary}, shellArgs...) // platform-specific + if exitOnError { + argv = append(argv, "-e") + } + return append(argv, "-u", "-o", "pipefail", "-c", command) +} +``` + +### Code changes + +- `src/process/process.go` — `ExecWithTimeoutShellStdStreams` takes the shell from config + instead of the literal `"bash"`. `BashCommand` gains an args parameter. +- `src/run/run_step.go` — same. +- `src/cache/cmd_cache.go` — replace hardcoded `exec.Command("sh", "-c", …)` (two sites) + with the same knob. + +### Packaging + +Vendor `busybox.exe` via a `remote_file` with a pinned SHA-256, and add it to +`//package:installed_files` under `is_platform(os = "windows")`. The pattern to copy is the +Linux-only `ldd` assertion in `src/BUILD.plz`. + +Pin an exact release. busybox-w64 is a third-party fork +(); record the source URL, version and hash in +`third_party/binary/BUILD` so the provenance is auditable, and note the licence (GPL-2.0) +in the release's licence file. + +## The path-format rule + +**Implemented and verified.** `BuildEnv.normalisePathSeparators` (`src/core/pathsep_windows.go`) +rewrites the paths Please generates; `TestBuildEnvironmentUsesForwardSlashes` asserts the +invariant. + +The hazard is narrower than first assumed, and worth stating precisely, because the obvious +mental model is wrong. Shell *variable expansion* does not reprocess escapes, so a backslash +path survives `echo "$TMP_DIR"` and `printf '%s' "$TMP_DIR"` intact. It is passing the value +to something that interprets its own arguments that destroys it. Measured under Wine: + +```console +$ # cmd = echo placeholder | sed -e "s#placeholder#$TMP_DIR#" > $OUT +Z:^Impclaude-1000-...scratchpadwinrepoplz-out^Impsedtest._build +``` + +`\t` became a literal tab and every other backslash was consumed. The C/C++ rules build their +link line with `sed`, so this is squarely on the path of the primary use case. + +Build actions receive paths through the environment — `$TMP_DIR`, `$OUT`, `$OUTS`, `$SRCS`, +`$SRCS_`, `$TOOLS_` — assembled in `src/core/build_env.go`. Those values are +interpolated into a **shell string**, where `\` is an escape character. A Windows path like +`C:\plz-out\tmp\foo` becomes `C:plz-outtmpfoo` after one round of shell processing. + +**The rule: Please uses forward slashes everywhere inside `plz-out` and everywhere in the +build environment, on every platform, including Windows.** + +Three reasons: + +1. **Win32 accepts forward slashes.** `CreateFileW` and the whole `Win32` file API treat `/` + and `\` interchangeably. So does MinGW GCC. So does busybox. +2. **It keeps hashes identical across platforms.** Please's cache is content-hash based over + the rule definition and environment. If `$OUT` is `a\b` on Windows and `a/b` on Linux, + every target hash diverges — which is correct but wasteful, and makes cross-platform + remote cache sharing impossible. +3. **It is the smaller change.** `filepath.Join` produces `\` on Windows, so the conversion + point is well-defined: normalise on the way *into* the build environment + (`BuildEnvironment`, `toolPath`) rather than auditing every producer. + +**Exceptions**, which must be explicit and commented: + +- Absolute paths with a drive letter (`C:/...`) are fine with forward slashes and should + keep the drive letter. +- UNC paths (`\\server\share`) cannot be normalised. Detect and reject them as a repo root + with a clear error rather than producing corrupt commands. +- Paths passed to Windows APIs directly (not through the shell) keep whatever + `filepath` produces. Only the *build environment* is normalised. + +Add a test in `src/core/build_env_test.go` asserting no `\` appears in any value returned by +`BuildEnvironment` on Windows. + +### Related: `HOME` and `TMPDIR` + +`src/core/build_env.go` sets `HOME=tmpDir` and `TMPDIR=tmpDir` for every action. On Windows, +tools look at `USERPROFILE` and `TEMP`/`TMP`. Set all of them (M2), pointing at the same +normalised tmp dir, so the hermetic-environment guarantee holds for Windows-native tools too. + +## Exit criterion + +```bash +# under Wine, with the bundled shell +wine plz-out/bin/windows_amd64/src/please.exe build //test/genrule:pipeline_test +``` + +where the target is a `genrule` with `cmd = "cat $SRCS | sort > $OUT"`. That exercises +argument interpolation, a pipe, a redirect and two applets in one action. diff --git a/docs/design/windows/03-cc-toolchain.md b/docs/design/windows/03-cc-toolchain.md new file mode 100644 index 0000000000..3acccc1fb1 --- /dev/null +++ b/docs/design/windows/03-cc-toolchain.md @@ -0,0 +1,304 @@ +# C/C++ Toolchain on Windows + +Status: **Draft** · Milestone: M5 · Workstream B (`please-build/cc-rules`) · Last updated: 2026-09-10 + +ADR for decision **D1**: target MinGW-w64 GCC first, MSVC later. Building C++ projects is the +programme's driving use case, so this is the document that matters most. + +## Where the rules live + +**Not in this repo.** `plugins/BUILD` pins `please-build/cc-rules` at `v0.7.3`, fetched as a +plugin subrepo via `plugin_repo` (`rules/subrepo_rules.build_defs`), from +`https://github.com/please-build/cc-rules/archive/v0.7.3.zip`. + +The only in-repo consumer of cc rules is `tools/sandbox/BUILD`. + +There is also a **dead** `[Cpp]` config section in `src/core/config.go` (`CCTool`, `CppTool`, +`LdTool`, `ArTool`, …). It is a no-op in plz v17+ — the code even warns *"You're overriding +field %s which is deprecated in plz v17+"*. Ignore it; the live config is the plugin's +`[PluginConfig …]` block, addressed as `CONFIG.CC.*` in BUILD files and `-o plugin.cc:…` on +the command line. + +## Decision: MinGW-w64 first + +### Why not MSVC first + +MSVC is what most real Windows C++ projects use, and it is the eventual target. But choosing +it first means taking on three unknowns simultaneously: + +- a completely different flag dialect (`/c`, `/Fo`, `/EHsc`, `/link`, `.obj`, `.lib`), +- `vcvarsall.bat` environment discovery (INCLUDE/LIB/PATH, SDK version selection), +- and a Windows host, because `cl.exe` does not run on Linux. + +That last point is disqualifying on its own. It would block every C++ change on the same +native-Windows CI that M9 exists to defer. + +### Why MinGW + +`x86_64-w64-mingw32-g++` **runs on Linux**. That is Axis 2 from `00-overview.md`: the entire +C++ codepath becomes testable on the development platform, producing real PE32+ binaries, +before any Windows machine exists. + +And the flag surface is almost entirely reusable. `_binary_build_flags` in +`build_defs/cc.build_defs` already branches Apple-vs-GNU throughout, via `please_cc`'s +expression language: + +```python +oflags += ["""'{{ !ld64 && !appleld ? ["-Wl,--start-group", "-Wl,--whole-archive"] }}'"""] +``` + +MinGW's `ld` is GNU ld. **It takes the GNU branch for free.** That is the entire payoff of +D1. + +## `please_cc` — the extension point + +The rules do not emit compiler command lines directly. They emit `please_cc` invocations: + +``` +"$TOOLS_PLEASE_CC" cc "$TOOLS_CC" -c -I . ${SRCS_SRCS} +``` + +`please_cc` (`tools/please_cc/`, ~1000 lines of Go) runs the compiler with `-v -Wl,-v`, +regex-matches the output to identify the compiler *and* the linker it will invoke, evaluates +any `{{ … }}` expressions in the arguments against that identity, and `exec`s the real tool. + +Known identities today (`tools/please_cc/cctool/tool.go`): GCC, Clang, Apple Clang, GNU ld, +GNU gold, LLD, ld64, Apple ld. + +### The assumption D1 rests on — **confirmed** + +Measured against two toolchains, both by regex and in the real pipeline: + +| Toolchain | Compiler line | Linker line | `please_cc` says | +|---|---|---|---| +| WinLibs GCC 16.2.0 (Windows-native, under Wine) | `gcc version 16.2.0 (MinGW-W64 …)` | `GNU ld (Binutils for MinGW-W64 …) 2.47.20260726` | GCC 16.2.0 / GNU ld 2.47.20260726 | +| Ubuntu `g++-mingw-w64-x86-64` 13 (Linux cross) | `gcc version 13-win32 (GCC)` | `GNU ld (GNU Binutils) 2.41.90.20240122` | GCC 13 / GNU ld 2.41.90 | + +Both match the existing GCC and GNU ld matchers, and the Clang matcher correctly does not. +**No new matchers are needed.** + +Note the Ubuntu build reports `13-win32`, so the captured version is a bare `13`. +`MustParseVersion` handles a single component, and `Compare` zero-pads the shorter of two +version numbers, so `gcc >= 9` style expressions still evaluate correctly. + +The original check, kept for reference: + +```bash +x86_64-w64-mingw32-g++ -v -Wl,-v 2>&1 | head -20 +``` + +Check the output against these two patterns from `cctool/tool.go`: + +``` +^gcc (?:version|\(GCC\)) (?P[\d.]+) +^GNU ld (?:\(.*\) |version )(?P\d+(?:\.\d+)*) +``` + +If either fails to match, `please_cc` exits with *"failed to identify C/C++ compiler; please +report the output of … to "* and **every cc target fails**. If that happens, the +fix is small (add a matcher) but it must be known up front, not discovered mid-milestone. + +Note the ordering constraint documented in `tool.go`: the Apple Clang matcher must run before +the Clang matcher because Go's `regexp` has no zero-length assertions. Any new matcher must be +placed with the same care. + +### `please_cc` needs a Windows build + +`tools/please_cc/please_cc.go` ends in: + +```go +func execvp(file string, args []string) error { + execFile, err := exec.LookPath(file) + ... + return syscall.Exec(execFile, append([]string{file}, args...), os.Environ()) +} +``` + +`syscall.Exec` does not exist on Windows. Add `execvp_windows.go` that spawns, waits and +propagates the exit code — the same shape as `process.ExecReplace` in `01-os-abstraction.md`. + +**Priority note:** under Please's cross-compilation model, `tools` are always built for the +*host* arch. So for Axis 2 (cross-building C++ from Linux) `please_cc` runs as a Linux +binary and this is not on the critical path. It is only required for a native Windows `plz`. +Sequence it accordingly. + +## Output extensions + +Current naming, all in `build_defs/cc.build_defs`: + +| Rule | Output | MinGW needs | +|---|---|---| +| `cc_object` | `.o` | unchanged — MinGW uses `.o` | +| `cc_library` | `lib.a` | unchanged — MinGW uses `.a` | +| `cc_static_library` | `lib.a` | unchanged | +| `cc_shared_object` | `lib.so` | **`.dll`**, plus `lib.dll.a` (import library) as an `optional_out` | +| `cc_binary` | `` (bare) | **`.exe`** | +| `cc_test` | `` (bare) | **`.exe`** | + +This is the one place MinGW's GNU-ness does not carry over, and it is why D1 saves work +rather than eliminating it: only two of six naming schemes change. + +Gate on `CONFIG.OS == "windows"`. The rules already precedent OS-conditional logic: + +```python +if CONFIG.TARGET_OS == "darwin" and static: + log.warning("%s: statically-linked binaries are unsupported on Darwin; ignoring 'static'") + static = False +``` + +Also add `windows_amd64` to `SUPPORTED_ARCHITECTURES` in `build_defs/arch.build_defs`, which +today lists only the five released platforms. + +### Consequence for `plz run` and `cc_test` + +`cc_binary` sets `binary = True` and `outs = [name]`. Changing the out to `name + ".exe"` +changes the path `plz run` and the test runner resolve. Per `01-os-abstraction.md`, core is +*not* being taught about `.exe`, so verify early that `plz run //some:cc_binary` works with +the renamed output — this is the specific case that would force a rethink. + +## Flag review + +Every flag in `_build_flags` and `_binary_build_flags`, assessed for PE/COFF via MinGW. + +| Flag | Where | Verdict | +|---|---|---| +| `-c`, `-I .` | `_library_cmds` | Fine | +| `-fPIC` | `_build_flags` | **Remove for Windows.** Meaningless for PE; MinGW emits *"-fPIC ignored for target"* on every compile. Noise, not breakage — but it pollutes every build log. | +| `-fdata-sections`, `-ffunction-sections` | `_build_flags` (LdGarbageCollection) | Fine — supported by MinGW GCC | +| `-fno-unique-section-names` | `_build_flags` | Clang-only branch already; no change | +| `-Wl,--start-group` / `--end-group` | `_binary_build_flags` | Fine — GNU ld branch, taken automatically | +| `-Wl,--whole-archive` / `--no-whole-archive` | `_binary_build_flags` | Fine — same | +| `-Wl,--build-id=none` | `_binary_build_flags` | **Remove for Windows.** ELF-only; MinGW ld errors or warns. Guard the existing `{{ gnuld \|\| gold \|\| lld ? … }}` expression with an OS check, since MinGW ld *does* match `gnuld`. | +| `-Wl,--gc-sections` | `_binary_build_flags` | Fine — MinGW ld supports it | +| `-Wl,--strip-all` | `_binary_build_flags` | Fine | +| `-shared` | `_binary_build_flags` | Fine — produces a DLL | +| `-static` | `cc_binary` | Works, but means "static libgcc/libstdc++" rather than a fully static image. Document the difference; do not silently disable it as Darwin does. | +| `-static-libgcc` | `cc_binary` (gcc branch) | Fine | +| `--coverage`, `-fprofile-dir=.` | `_COVERAGE_FLAGS` | Works with MinGW + `gcov`, but the `cover` command copies `.gcno` from `$GCNO_DIR` and shells out — verify end-to-end in M6 rather than assuming | +| `DefaultLdFlags = -lpthread -ldl` | `.plzconfig` | **Both wrong on MinGW.** `-ldl` does not exist; `-lpthread` is unnecessary (winpthreads is implicit) and may not resolve. Needs a `windows_amd64` override — set `DefaultLdFlags` to empty. | + +The `-fPIC` and `-Wl,--build-id=none` changes both need a way to express "target OS is +windows" inside the `{{ … }}` expression language, or an `if CONFIG.OS == "windows"` in the +Python-side flag assembly. **Prefer the latter** — the expression language identifies +*tools*, not targets, and overloading it with OS knowledge would be a category error. + +## `pkg-config` + +`_build_flags` and `_binary_build_flags` emit backticked `pkg-config --cflags` / +`--libs` invocations. There is no `pkg-config` in busybox and no Windows convention for it. + +**Decision: document as unsupported on Windows.** Leave the codepath intact — it will simply +fail if used — and have users pass `compiler_flags`/`linker_flags` explicitly. Do not ship a +`pkg-config` shim; that is a package-management problem, not a build-system one. + +Emit a clear diagnostic rather than a shell "command not found": add a check in the rules +that raises at parse time when `pkg_config_libs` is set and `CONFIG.OS == "windows"`. + +## What MSVC would later require + +Recorded so the extension point stays visible, not as scheduled work. + +1. **New `cctool` matchers.** `cl.exe /?` prints *"Microsoft (R) C/C++ Optimizing Compiler + Version 19.NN.NNNNN"*; `link.exe` prints *"Microsoft (R) Incremental Linker Version …"*. + Note that `please_cc` currently probes with `-v -Wl,-v`, which MSVC does not understand — + the probe itself needs to become tool-family-aware, which is a deeper change than adding + a regex. +2. **A second flag dialect.** Not a translation layer — a parallel set of flag-assembly + functions selected by toolchain, because the mappings are not one-to-one + (`--whole-archive` → `/WHOLEARCHIVE:lib`, `--gc-sections` → `/OPT:REF`, and + `--start-group` has no equivalent at all because MSVC's linker does not care about + library order). +3. **Separate archiver and linker tools.** MinGW links through the compiler driver + (`$TOOLS_CC`); MSVC needs `lib.exe` and `link.exe` as distinct tools. The plugin config has + no `LdTool` today — it would need one. +4. **`vcvarsall` environment discovery.** `INCLUDE`, `LIB`, `LIBPATH` and SDK version + selection. This conflicts with Please's hermetic-environment model + (`src/core/build_env.go` builds the env from scratch rather than inheriting), so it needs + a deliberate design — most likely a `pass_env` allowlist plus a documented setup step. +5. **`.obj`/`.lib`/`.pdb`** output naming, and `/showIncludes` if header scanning is ever + added (it is not today — see below). + +`clang-cl` is a middle path: one binary, MSVC-compatible flags, already partially matched by +the existing Clang regex. It still needs items 2, 4 and 5. + +## What is pleasantly absent + +**No header-dependency scanning.** There is no `-MD`, `-MF`, `-MMD` or `.d` handling +anywhere in the rules. Please does not scan headers; correctness comes from declared +`hdrs`/`private_hdrs` plus the sandboxed tmp dir. + +This removes a whole class of portability work — no dep-file path munging, no +`/showIncludes` parsing when MSVC eventually lands. + +## Development and upstreaming + +Point `plugins/BUILD` at a fork or branch revision during development: + +```python +plugin_repo( + name = "cc", + plugin = "cc-rules", + revision = "", +) +``` + +Upstream to `please-build/cc-rules` as the final step of M5. Per `CONTRIBUTING.md`, raise the +issue in that repo *before* writing the code. + +Extend the plugin's own CI (`.github/workflows/plugin_test_cc.yaml`) with a MinGW +cross-compile job on `ubuntu-latest` — `apt-get install g++-mingw-w64-x86-64` plus +`plz build --arch windows_amd64 //test/...`. + +## Exit criterion — met + +On a Linux box, in the cc-rules repo with `cc-rules-windows.patch` applied: + +```console +$ plz build --arch windows_amd64 //test/binary:test_binary +plz-out/bin/windows_amd64/test/binary/test_binary.exe +$ file plz-out/bin/windows_amd64/test/binary/test_binary.exe +PE32+ executable (console) x86-64, for MS Windows +``` + +A `cc_library` + `cc_binary` + `cc_shared_object` triple produces `lib.a`, `prog.exe` and +`libshared.dll`; `prog.exe` links against the static library and prints the right answer under +Wine. The same targets still produce `prog` and `libshared.so` on Linux, and all 12 of +cc-rules' own tests pass there. + +### What the experiments changed + +1. **Module-level `CONFIG` does not see the target architecture.** The first attempt defined + `_EXE_SUFFIX` as a module-level constant and it silently had no effect — these build defs + are subincluded, and `CONFIG.OS` at module level reflects the host. It has to be a function + evaluated per call. This is a trap for any future platform-conditional logic here. +2. **A repeatable config key cannot be cleared by assigning empty.** `defaultldflags =` + yields a list containing one empty string rather than an empty list, which + `_escape_linker_flag` turns into a bare `-Wl,` and the linker rejects with + `cannot find : Invalid argument`. Set an actual value instead. +3. **`-lpthread` is fine on MinGW**, so only `-ldl` had to go. The Windows default is + `defaultldflags = -lpthread`. +4. **`-fPIC` and `-Wl,--build-id=none` were passed and neither broke the link.** They remain + worth removing as noise, but they are not blockers, so that is deferred rather than done. +5. **A `cc_shared_object` that sets `out` explicitly keeps whatever extension it was given.** + The rules' *default* is now correct, but a BUILD file hardcoding `out = "libfoo.so"` — as + cc-rules' own `//test/so:libdolphin` does — will still produce a `.so` on Windows. That is + arguably right, since `out` is an explicit instruction, but it is a portability trap worth + documenting for users. + +### Still open + +- **`please_cc` has no `windows_amd64` release.** `tools/BUILD` fetches it as a prebuilt + binary per platform with a pinned hash, so upstreaming needs a Windows build published + alongside the others. It did not block this work because tools are built for the *host*, + which is Linux under Axis 2 — but a native Windows `plz` will need it. +- ~~**`UnitTest++` does not compile for Windows** as packaged: it needs its `Win32/` platform + sources, which the plugin's target does not include.~~ **Wrong, and it cost time.** The + plugin's `unittest.build` has selected `Win32` on Windows since before this port started. + What actually blocked `cc_test` was that the sources were selected correctly and then + compiled by `/usr/bin/c++`: the UnitTest++ test main is a `cc_library` *inside the plugin*, + and a target inside a plugin sees the `PluginConfig` defaults rather than the using repo's + `[Plugin "cc"]` values. The toolchain the user configures applies to their code and not to + the plugin's. Fixed by defaulting the tools per platform in the build defs, which is where + the rest of the platform handling already lives. `//test/windows:cc_test_test` guards it. +- `SUPPORTED_ARCHITECTURES` still lacks `windows_amd64`; it gates the plugin's own release + rather than its use. diff --git a/docs/design/windows/04-release-and-ci.md b/docs/design/windows/04-release-and-ci.md new file mode 100644 index 0000000000..4ad467d512 --- /dev/null +++ b/docs/design/windows/04-release-and-ci.md @@ -0,0 +1,275 @@ +# Release Pipeline and CI + +Status: **Draft** · Milestone: M4 · Last updated: 2026-09-10 + +How `windows_amd64` artifacts get built on Linux, signed and published. This is Axis 1 from +`00-overview.md`. + +## The template: FreeBSD + +Please already cross-compiles a platform it does not test natively on CircleCI. The +`build-freebsd` job runs **on Linux**, in a Docker image, using a prebuilt Linux `plz`: + +```yaml +build-freebsd: + docker: + - image: ghcr.io/thought-machine/please_freebsd_builder:20260318 + steps: + - checkout + - attach_workspace: { at: /tmp/workspace } + - run: + name: Extract plz + command: tar -xzf /tmp/workspace/linux_amd64/please_*.tar.gz + - run: + name: Cross-compile + command: ./please/please build -p -v2 --profile ci --arch freebsd_amd64 //package:release_files + - persist_to_workspace: + root: plz-out/pkg + paths: [ freebsd_amd64/* ] +``` + +`build-windows` is a near-verbatim copy. **Copy it; do not invent a new shape.** + +Note the dependency: `build-freebsd` `requires: [build-alpine]`, because `build-alpine` +produces the canonical `linux_amd64` release tarball that every cross job extracts and runs. +`build-windows` takes the same dependency. + +The builder image is equally simple — `tools/images/freebsd_builder/Dockerfile` is 13 lines: + +```dockerfile +FROM ubuntu:noble +RUN apt-get update && apt-get install -y curl git gcc xz-utils && apt-get clean +RUN curl -fsSL https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz | tar -xzC /usr/local +RUN ln -s /usr/local/go/bin/go /usr/local/bin/go && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt +RUN GOOS=freebsd go install std +``` + +`tools/images/windows_builder/Dockerfile` is the same with `GOOS=windows go install std`, +plus `g++-mingw-w64-x86-64` in the apt line for Axis 2. Add the image name to the list in +`tools/images/build.sh`; images are tagged by date and pushed to +`ghcr.io/thought-machine/please_`. + +## Prerequisites + +Four things must land before the cross-build can even start. + +### 1. Go toolchain hash + +`third_party/go/BUILD` pins per-platform SHA-256s for the Go distribution: + +```python +go_toolchain( + name = "toolchain", + hashes = [ + "…", # go1.27.0.darwin-amd64.tar.gz + … + ], + version = "1.27.0", +) +``` + +Add `go1.27.0.windows-amd64.zip`. Note Windows Go distributions are `.zip`, not `.tar.gz` — +confirm the go plugin's `go_toolchain` rule handles that, or the hash is useless. + +### 2. arcat — the real gate + +**What arcat is.** A small standalone Go binary (`github.com/please-build/arcat`, 6 source +files) that Please downloads as a prebuilt release keyed by `HOSTOS_HOSTARCH`. It is the +built-in archive toolkit, so rules never depend on the host having `tar`, `zip`, `ar` or +`unzip`, or on those behaving consistently. Three uses: + +| Invocation | Used by | +|---|---| +| `arcat x` — extract zip/tar | `remote_file(extract=True)`, `http_archive`, **`plugin_repo`** | +| `arcat tar` — create tarballs | `tarball()` | +| `arcat ar -r` / `--combine` — create/merge `.a` static libraries | **`cc_library`** | + +**Why it is more severe than "a hash to add".** `plugin_repo` extracts the plugin zip with +it, and *every language plugin is delivered that way*. So without arcat, Windows cannot load +the cc rules at all — and `cc_library` then needs it again to build `.a` archives. It is on +the critical path for any real build. Measured: `plz.exe` under Wine fails with + +``` +failed to generate internal package: arcat tool not supported for platform: windows_amd64 +``` + +as soon as a plugin is involved. Simple genrules and parsing work without it, which is why +the earlier assessment understated this. + +**Why it is nevertheless easy.** arcat is pure Go with **no** `syscall`, `x/sys/unix` or cgo +usage anywhere. Verified: it cross-compiles to a PE32+ binary, and under Wine both +critical paths work — `arcat x` extracts a zip correctly, and `arcat ar -r` produces a `.a` +that MinGW links into a working `.exe`. + +One snag, and it is not a Windows one: arcat's `go.mod` still says `go 1.17` while the code +uses generics, so it fails to build on *any* platform with a modern toolchain +(`implicit function instantiation requires go1.18 or later`). Bumping the directive is a +one-line fix that should go upstream regardless. + +**So the work is:** publish a `windows_amd64` arcat release alongside the others, then add its +hash to the switch in `src/parse/internal_package.go`. The switch is exhaustive and hard-fails +by default, which is what produces the error above. + +### 3. `.plzconfig_windows_amd64` + +Per-arch config, layered by `state.ForArch` (`src/core/state.go`). Compare +`.plzconfig_freebsd_amd64`, which is two lines. + +```ini +[Plugin "cc"] +cctool = x86_64-w64-mingw32-gcc +cpptool = x86_64-w64-mingw32-g++ +artool = x86_64-w64-mingw32-ar +defaultldflags = ; -lpthread -ldl are both wrong on MinGW + +[build] +xattrs = false + +[sandbox] +build = false +test = false +``` + +### 4. `//package:installed_files` must stop pulling in the Linux sandbox + +```python +filegroup( + name = "tools", + srcs = [ + "//tools/build_langserver", + "//tools/sandbox:please_sandbox", # <- C binary of Linux-namespace code + ], +) +``` + +`please_sandbox` is a `c_binary` whose source is `#ifdef __linux__` throughout, with a no-op +fallback. It has no meaning on Windows and building it requires a C toolchain for the target. +Gate it with `is_platform(os = "linux")` (`rules/misc_rules.build_defs`; see `src/BUILD.plz` +for the usage pattern). + +**This is already latently wrong for the FreeBSD cross build**, which gets away with it +because `.plzconfig_freebsd_amd64` points `cctool` at the host Linux `cc`. Fixing it properly +benefits both platforms. + +## Packaging + +### Add a `.zip` alongside the tarballs + +`package/BUILD` produces `please_.tar.gz`, `.tar.xz` and a tools tarball. Windows +has no guaranteed `tar -xJ`; ship a zip. + +Note from `02-shell-and-build-actions.md`: busybox has `unzip` but not `zip`, and its `xz` is +decompress-only. Both are irrelevant here because release artifacts are *produced* on Linux — +but it does mean the xz tarball rule should be gated to Linux rather than attempted on +Windows. + +### Contents of the Windows release + +| File | Source | +|---|---| +| `please.exe` | `//src:please` | +| `busybox.exe` | vendored `remote_file`, pinned hash — see `02-shell-and-build-actions.md` | +| `build_langserver.exe` | `//tools/build_langserver` | +| — | **no** `please_sandbox` | + +`//package:installed_files` sets `entry_points = {"please": "please"}` — verify this resolves +with the `.exe` suffix, or add a Windows-conditional entry point. + +### The `plz` alias + +`install.sh` does `ln -sf please plz`. On Windows, symlinks need Developer Mode. Ship a +`plz.cmd` one-liner (`@"%~dp0please.exe" %*`) instead — a file copy is also acceptable but +doubles the download size. + +## Bootstrap and self-update + +### `pleasew` + +`pleasew` is POSIX `sh` and has an explicit OS whitelist: + +```sh +Linux|Darwin|FreeBSD) ;; +*) echo "Please does not support the %s operating system"; exit 1 ;; +``` + +**Do not make it polyglot.** Add a sibling `pleasew.ps1` (PowerShell) implementing the same +flow: find repo root, read `.plzconfig`/`.plzconfig__` for the version, download +`${URL_BASE}/windows_amd64/${VERSION}/please_${VERSION}.zip`, extract, exec. + +`pleasew` is embedded into the binary via `src/assets/BUILD` (`plz init` writes it out), so +`pleasew.ps1` needs adding there and to the root `BUILD` filegroup too. + +### `src/update/update.go` + +The download URL is already built from `runtime.GOOS`/`runtime.GOARCH`: + +```go +url = fmt.Sprintf("%s/%s_%s/%s/please_%s%s", DownloadLocation, GOOS, GOARCH, Version, Version, ext) +``` + +so it works as soon as the bucket has a `windows_amd64/` folder. Three things around it do +not: + +- `syscall.Exec(newPlease, …)` to hand over to the new binary → `process.ExecReplace` + (`01-os-abstraction.md`). +- `writeTarFile` recreates `tar.TypeSymlink` members → needs the M2 copy-fallback. +- The binary is opened with mode `0555` and `fileMode()` returns `0664`/`0775` → harmless on + Windows, but the symlink at the end (`please` → version dir) is not. + +Also: **Windows will not let you overwrite a running executable.** The self-updater must +rename the running `please.exe` aside before writing the new one, or update into a +version-stamped directory and switch a `.cmd` shim. The version-directory layout Please +already uses (`~/.please//`) makes the second option natural. + +### `tools/please_shim` + +Same exec-replace problem, plus `filepath.Join(Location, "please")` needs `.exe`. Covered in +`01-os-abstraction.md`. + +## CI wiring + +`.circleci/config.yml`: + +1. New `build-windows` job (copy `build-freebsd`), `requires: [build-alpine]`. +2. Add it to the workflow `jobs:` list. +3. Add it to `release-gs`'s `requires:` list alongside `build-freebsd`. + +`.circleci/release.sh`: + +```sh +release_folder /tmp/workspace/windows_amd64 windows_amd64/$VERSION +``` + +The signing globs above it are `{*_amd64,*_arm64}`, which **already match** `windows_amd64` — +so signing needs no change, but note that means an unreleased `windows_amd64` folder in the +workspace would be signed and then silently dropped. Add the `release_folder` line in the +same commit as the CI job, not later. + +The idempotency guard at the top of `release.sh` checks whether +`gs://get.please.build/linux_arm64/$VERSION/` exists. Leave it — adding Windows to it would +make the first Windows release re-upload everything. + +`tools/misc/gen_release.py` — its `_arch()` helper defaults anything non-darwin/non-freebsd +to `linux_*`. Add a windows branch, or the GitHub release assets get mislabelled. + +## Non-blocking guardrail (M0) + +Before any of the above, add a **non-blocking** job that runs: + +```bash +plz build --arch windows_amd64 //src:please +``` + +It will fail. That is the point: it makes the compile-error count visible and +monotonically decreasing, and it catches regressions from contributors who are not thinking +about Windows. Record the initial output in `appendix-baseline-errors.md`. + +## Exit criterion + +```bash +plz build --arch windows_amd64 //package:release_files +ls plz-out/pkg/windows_amd64/ +# please_.zip, please_.tar.gz, please_, please_shim_ +``` + +on a Linux CI box, with the artifacts signed by the existing `release_signer` step. diff --git a/docs/design/windows/05-testing-strategy.md b/docs/design/windows/05-testing-strategy.md new file mode 100644 index 0000000000..1960fa1a63 --- /dev/null +++ b/docs/design/windows/05-testing-strategy.md @@ -0,0 +1,291 @@ +# Testing Strategy + +Status: **Draft** · Milestone: M6 (with M9 as the follow-up) · Last updated: 2026-09-10 + +The programme constraint is that development and CI stay on Linux, with real Windows testing +deferred. This document is how that is made to work rather than merely asserted. + +## Four test loops + +| Loop | Runs | Tests | Available from | +|---|---|---|---| +| **A — compile gate** | Linux, natively | Does `plz.exe` build for `GOOS=windows`? | M0 | +| **B — C++ cross-build** | Linux, natively | Do the cc rules produce correct PE32+ artifacts? | M5 | +| **C — Wine** | Linux, under Wine | Does `plz.exe` actually *run*? | M1 onwards | +| **D — the documentation** | Windows, natively | Do the published codelabs work as written? | M10 | + +Loops A and B need no emulation at all. Loop C is where the leverage is, and it is why M6 +should start as soon as M1 produces a binary — the milestone number is a completion point, +not a start date. + +## Loop A — compile gate + +```bash +plz build --arch windows_amd64 //src:please +``` + +Wired into CI as a non-blocking job in M0 (see `04-release-and-ci.md`). Its output is the +burn-down list in `appendix-baseline-errors.md`. + +Cheap, fast, and catches the majority of M1's work. It catches nothing about behaviour. + +## Loop B — C++ cross-build (Axis 2) + +```bash +plz build --arch windows_amd64 //test/cc/... +file plz-out/bin/windows_amd64/test/cc/binary.exe +# PE32+ executable (console) x86-64, for MS Windows +``` + +`plz` runs as a native Linux binary; `x86_64-w64-mingw32-g++` runs as a native Linux binary; +the *output* is Windows. Nothing is emulated. + +**This validates roughly 80% of the C++ work**: flag assembly, output naming, the +`please_cc` tool-identification path, archive combination, transitive label propagation, and +the whole `cc_library` → `cc_binary` graph. All of it before a Windows machine exists. + +What it does not validate: that the same flags are produced when `plz` itself is running on +Windows with the bundled busybox shell. That needs Loop C. + +Assertions worth making beyond `file(1)`: + +- `x86_64-w64-mingw32-objdump -p out.exe` — check the import table names the expected DLLs. +- `x86_64-w64-mingw32-nm` on the archive — check `--whole-archive` actually pulled symbols in + for an `alwayslink` library. +- Run the produced `.exe` under Wine — which is Loop C applied to the *output* rather than to + `plz`. + +## Loop C — Wine + +**Proven, not hypothetical.** Wine 9.0 (the Ubuntu/Pop!_OS package, `apt install wine64`) +already runs `please.exe` end to end: version, `query alltargets //...`, and real builds +including shell pipelines. See `appendix-baseline-errors.md`. + +Wine implements precisely the primitives the port introduces: Job Objects, `LockFileEx`, +`CreateProcess`, console control events, `PATHEXT` resolution. That is not a coincidence — +they are the well-trodden Win32 core, which is what Wine covers best. + +Setup used: + +```bash +export WINEPREFIX=$PWD/.wineprefix WINEDEBUG=-all +wineboot --init +# busybox-w64 as the build shell +cp busybox64.exe wbin/bash.exe +export WINEPATH='Z:\path\to\wbin' +``` + +`WINEDEBUG=-all` suppresses Wine's chatter; a job-local `WINEPREFIX` keeps runs isolated. + +**Clear Please's cache between runs, or you will verify nothing.** `rm -rf plz-out` is not +enough: Please also keeps a directory cache, which under Wine lands in +`$WINEPREFIX/drive_c/users//AppData/Local/please`. A build that appears to succeed may +be replaying cached artifacts from an earlier, differently-built binary — this happened during +M0 and produced a false pass on a binary whose shell handling was in fact broken. Wipe both: + +```bash +rm -rf plz-out "$WINEPREFIX"/drive_c/users/*/AppData/Local/please +``` + +(Incidentally this confirms `os.UserCacheDir()` resolves correctly on Windows.) +Note Wine maps Unix paths to the `Z:` drive, so `$PWD` becomes `Z:\...` inside the binary — +useful to know when reading error messages. + +```bash +wine plz-out/bin/windows_amd64/src/please.exe --version +wine plz-out/bin/windows_amd64/src/please.exe build //test/cc:binary +wine plz-out/bin/windows_amd64/test/cc/binary.exe +``` + +### What to run under it + +1. **Unit tests.** Add a test macro that runs a Go test binary cross-compiled for Windows + under `wine`. The high-value packages are exactly the ones M1 and M2 touch: + `src/core` (`lock_test.go`, config loading), `src/fs`, `src/process`. +2. **The genrule smoke test** from `02-shell-and-build-actions.md` — a `cmd` with a pipe and + a redirect, proving the bundled shell is wired up. +3. **The headline end-to-end test**, combining both axes: `wine plz.exe` building a C++ + project with MinGW. This is the M6 exit criterion and the single most valuable test in the + programme, because it is the first thing that exercises busybox, the generated cc command + lines, the Windows process layer and PE output *together*. + +### CI + +**Implemented.** `test/build_defs/wine.build_defs` has two macros — `wine_go_test` for a +cross-built Go test binary, and `wine_plz_test` for `please.exe` driving a small repo laid out +the way the release is. `//test/windows` uses them; the `test-windows-wine` CircleCI job runs +them and blocks the release. + +They are labelled `wine` and excluded from the other test passes, because building them means +cross-compiling the Go standard library for another platform. `test.sh` runs them as a third +pass where Wine is installed. + +**Never run these binaries by hand in the source tree.** Under `plz test` they run in a +sandboxed temp directory; run directly from the repo root they operate on the repo. Doing that +once during this work deleted the whole of `test/` — `AsyncDeleteDir` re-executes +`os.Executable()`, which for a test binary is the test binary, with arguments it then ignores. +Everything below was found by running them through the macros, which is the only safe way. + +Things learned building it: + +- **Rename the binary to `.exe` first.** Go's `exec` on Windows will not run a file whose name + has no extension in `PATHEXT`, even given its full path, and the go plugin names test + binaries after the rule. Any test whose subject re-execs itself — `TestComplete` in + `src/core` does — fails obscurely otherwise. +- **A `go_test`'s own `data` doesn't come with it** when another rule depends on the binary, so + anything the test reads has to be repeated on the `wine_go_test`. `$DATA` has to be set to + just that, too: a `gentest` would otherwise include the test binary in it, and tests that + read `$DATA` expect only their own data. +- **Wine's hosts file leaves the `localhost` line commented out.** Anything that resolves it + hangs until it gives up; three `remote_file` tests were each burning fifteen seconds. The + macro appends the line to the prefix. +- **`~` resolves inside the shared prefix**, so anything a test writes to its home directory + leaks into the next run — and a read-only file left there cannot be replaced on Windows at + all. Tests that write to `~` set `USERPROFILE` themselves, which is what `os.UserHomeDir` + reads there. + +A Wine job on `ubuntu-latest` (`apt-get install wine64`) is cheap. Make it blocking once M1 +lands — the whole point is to catch Windows regressions from contributors who are not +thinking about Windows. + +Set `WINEDEBUG=-all` to suppress Wine's chatter, and `WINEPREFIX` to a job-local directory so +the prefix is not shared between runs. + +## Loop D — the documentation + +The codelabs at https://please.build/codelabs.html are what a new user follows, and until this +loop nothing had ever executed a line of them, on any platform. Loop D replays them on +`windows-latest` with the release zip, the way a reader would. + +It is built in three parts, and only the last needs Windows: + +- `//test/windows/codelab_script` reduces `docs/codelabs/*.md` to a plan: the files each codelab + says to create and the commands it says to run, in order. It refuses to guess. A block no rule + can classify is an error, not a skipped block, so a codelab edit that introduces one fails + `//test/windows/codelab_script/script:script_test` on Linux, in the default test pass. +- `test/windows/codelab_steps.conf` records what the Markdown cannot say, each stanza with its + reason above it: that a `.plzconfig` block is a fragment to merge rather than a whole file, + that a block is output rather than a command, that a step cannot run on a CI machine at all. + Each stanza pins the text it was decided about, so an edit to that block fails extraction + rather than moving the decision onto something else. +- `test/windows/run_codelabs.ps1` replays the plan, handing each command to `pwsh` exactly as the + codelab writes it. Every step ends PASS, FAIL, KNOWN, SKIPPED or BLOCKED; a known failure that + starts passing fails the job, as in the unit-test job. + +**Wine contributes nothing here, and there is deliberately no Wine target for it.** What this +loop exists to find is PowerShell rejecting bash syntax, Unix tools that are not there, plugin +tools with no Windows release, and GitHub's API refusing anonymous callers. Wine emulates Win32 +and has no PowerShell; it can show none of those. + +What Linux can check before a push is everything except the execution, which covers all of the +bookkeeping that decides whether the job goes red: + +```bash +plz test //test/windows/codelab_script/... +plz build //test/windows:codelab_plan +pwsh ./test/windows/run_codelabs.ps1 -DryRun -Plan plz-out/gen/test/windows/codelab_plan.json \ + -KnownFailures test/windows/codelab_known_failures.txt +``` + +The runner's execution path was exercised on Linux once, against a synthetic plan with a fake +release, to cover every outcome and every rule that fails the job. Its answers about the real +codelabs only come from `windows-latest`. + +## What Wine does not cover + +Be honest about this. Wine passing is evidence, not proof. These are the M9 agenda, and they +should be listed in the M9 issue rather than discovered during it. + +**Measured on 2026-09-11**, by the GitHub Actions `windows-latest` job. The predictions below +were mostly right, and the list found one thing nobody had predicted. + +| Item | What a real Windows machine said | +|---|---| +| `ERROR_SHARING_VIOLATION` | **Materialised, and it broke `plz clean` outright.** Please held its own `plz-out/log/build.log` open and then asked Windows to delete the directory containing it. Both the background rename and the synchronous fallback failed, every time. Fixed by closing the log first; `RemoveAll` now also recognises the case and retries briefly rather than reporting it as a permissions problem | +| `MAX_PATH` | Not reproduced. The runner has long paths enabled, and a build at a 200-character path succeeded. Still untested with long paths off | +| Symlink privileges | `SeCreateSymbolicLinkPrivilege` is **disabled** on the runner, which is the case most users are in. `CopyOrLinkFile` already fell back; `buildLinks` did not, so every `link:` label quietly became a warning. Now falls back too, and the three tests that covered it no longer skip | +| Case-insensitivity | Not yet probed directly, but it caught `plz run` appending a second `PATH`: Windows stores the variable as `Path`, and the name was being compared exactly | +| Antivirus | Defender runs on the job, so every result above is already under a live scanner. No flakiness seen yet | +| Console, Ctrl-C | Still unreachable. A step's stdout is a pipe, so the interactive display never engages. Needs a machine with a real console session | +| **Unpredicted** | **`plz run` could not run anything on Windows.** An `sh_binary` is a `.cmd`, a `.cmd` runs through `cmd.exe`, and `cmd.exe` reads the forward slash in `plz-out/bin/x.cmd` as a switch. Wine's `cmd` parses it happily. Found by deleting a skip, not by adding a test | +| **Unpredicted** | **Any `plz` run outside a repo hung at 100% CPU for ever.** The walk towards the filesystem root never terminated, because trimming the separator off `C:\` leaves `C:` and splitting that returns it unchanged | + +### Filesystem semantics + +- **Case-insensitivity.** Wine on ext4 is case-*sensitive* by default. A BUILD graph with + `Foo.h` and `foo.h` works under Wine and collides on NTFS. Please's glob and hash code has + no case-folding anywhere. +- **`ERROR_SHARING_VIOLATION`.** Windows refuses to delete or rename a file that another + process has open. Wine is more permissive. This is the single most likely source of + real-Windows-only failures, and it hits exactly where Please works hardest: `plz-out/tmp` + teardown, `RemoveAll`, and the self-updater overwriting a running binary. +- **`MAX_PATH`.** 260 characters unless long-path support is enabled *and* the binary has the + manifest opt-in. `plz-out/bin///` nests deeply; a monorepo will hit + this. Wine does not enforce it. +- **Symlink privileges.** `os.Symlink` needs Developer Mode or + `SeCreateSymbolicLinkPrivilege`. This entry predicted Wine would grant it unconditionally, so + that the M2 copy-fallback path would never be exercised. **Measured, and it is worse than + that:** Wine's `os.Symlink` returns no error and produces a link that `os.Lstat` then cannot + find. `TestSymlink` skips on Windows for that reason. So Wine tells us nothing either way + here, and the fallback still needs testing by injecting a failure, not by hoping. + +### Process and console + +- **Real console behaviour.** VT sequence support (`golang.org/x/term`), the interactive + display in `src/output/interactive_display.go`, window resize. Wine's console is not + conhost. +- **Ctrl-C / Ctrl-Break delivery.** Wine's `GenerateConsoleCtrlEvent` is approximate. The + graceful-then-forceful kill path in `01-os-abstraction.md` needs native verification. +- **Antivirus.** Real-time scanning locks freshly written executables, causing intermittent + `ERROR_SHARING_VIOLATION` and slow builds. Invisible under Wine and a genuine user-facing + problem — worth a documented note in the eventual user docs. + +### Toolchain + +- **MSVC**, when it arrives, cannot be tested under Wine at all. + +## Regression protection for the platforms that already work + +Every change in M1–M3 touches shared code paths. Two things must hold for every PR: + +```bash +./bootstrap.sh # full build + unit + e2e on Linux +plz lint # golangci-lint + plz fmt check +``` + +**Hash stability is a hard requirement.** Please's cache is content-hash based over rule +definition, config, sources and secrets. Any change to command generation, environment +variables or config defaults shifts target hashes and invalidates every user's cache. Before +merging M3 in particular: + +```bash +plz hash //src/... # record +git stash -u -- src && plz hash //src/... # record again at HEAD +git stash pop +``` + +**Compare within one working directory.** Hashes are *not* comparable between two checkouts +of the same commit — a `git worktree` at HEAD produces different hashes from the main repo +for reasons unrelated to any change, so a worktree-vs-repo diff reports dozens of false +positives. Stash and unstash in place instead. + +Expect the dependency cone of whatever you edited to change; that is content hashing working. +What matters is that nothing *outside* that cone moves. + +A diff here is not necessarily wrong, but it must be *intended* and called out in the PR +description. + +**The e2e tests in `test/` assert on exact output text** and are documented as brittle. Expect +to update `.txt` golden files. Treat any *unexpected* change as a real regression rather than +noise — that is what they are there for. + +## Exit criterion for M6 + +A single CI job, on Linux, that: + +1. cross-builds `please.exe`, +2. cross-builds a C++ project for Windows using MinGW, +3. runs `please.exe` under Wine to drive that build, +4. runs the resulting `cc_test` binary under Wine and collects its results. + +If that passes, the Windows port is real, and M9 is about hardening rather than discovery. diff --git a/docs/design/windows/06-milestones.md b/docs/design/windows/06-milestones.md new file mode 100644 index 0000000000..b6842ebf73 --- /dev/null +++ b/docs/design/windows/06-milestones.md @@ -0,0 +1,773 @@ +# Milestone Tracker + +Status: **Living document** · Last updated: 2026-09-10 + +> **M0 is done and it changed the plan.** `plz.exe` compiles, links, parses BUILD files and +> executes build actions under Wine after ~290 lines of probe changes. Estimates below are +> revised down accordingly. See `appendix-baseline-errors.md` for evidence. + +The one file in this directory expected to change weekly. Update `Status` and `Notes` as work +lands; keep the exit criteria fixed unless the design genuinely changes, in which case update +the corresponding design doc too. + +Legend: ⬜ not started · 🟡 in progress · ✅ done · ⚠️ blocked + +## Summary + +| # | Milestone | Est. | Status | Owner | Issue | +|---|---|---|---|---|---| +| M0 | Baseline and guardrail | 2d | ✅ | — | — | +| M1 | OS abstraction layer | 1–2w | ✅ | — | — | +| M2 | Paths, environment and the `.exe` model | 1w | ✅ | — | — | +| M3 | Build actions and the bundled shell | 1w | ✅ | — | — | +| M4 | Release pipeline: cross-built Windows artifacts | 1w | 🟡 | — | — | +| M5 | C++ on Windows: cc-rules (workstream B) | 2w | 🟡 | — | — | +| M6 | Linux-hosted verification harness | 1w | ✅ | — | — | +| M7 | Sandboxing parity | 2w | 🟡 | — | — | +| M8 | Remote execution and plugin parity | 3w | 🟡 | — | — | +| M9 | Native Windows CI and GA | 2w | ⬜ | — | — | +| M10 | The codelabs, replayed on Windows | — | 🟡 | — | — | + +Rough total: 14–15 weeks of focused work. M0–M6 (the C++ vertical slice) is 7–8 weeks. + +**M1 was re-estimated from 2–3 weeks to 1–2 weeks.** Making it *compile* turned out to be a +two-day job (5 sites). The remaining time is the part the compiler cannot help with: Job +Objects, `ExecReplace`, and real file locking — all silent runtime failures today. + +## Sequencing + +``` +M0 ─┬─ M1 ─┬─ M2 ─┬─ M3 ─── M4 ─┐ + │ │ │ ├─ M6 ─┬─ M7 ─┬─ M9 + └─ M5 ─┴──────┴─────────────┘ └─ M8 ─┘ + (workstream B, parallel) +``` + +- **M5 can start immediately** and run in parallel with M1–M4. It is a different repo and its + Loop B verification (`05-testing-strategy.md`) needs only a Linux `plz` and MinGW — neither + of which depends on the core port. +- **M6 should start as soon as M1 produces a binary**, not after M5 finishes. The milestone + number is a completion point. +- **M7 is not a blocker for anything.** `sandbox_other.go` already compiles on Windows and + degrades to a plain `exec.Command`. + +## M0 — Baseline and guardrail ✅ + +**Exit:** the non-blocking CI job runs, fails, and its output is recorded in +`appendix-baseline-errors.md`. — *Met, and exceeded: the probe went all the way to a working +build under Wine.* + +- [x] Baseline measured — 5 compile-blocking sites in 4 packages, 4 layers +- [x] `pkg/xattr` verified: ships `xattr_unsupported.go`, no build tag needed +- [x] These design documents +- [x] `probe/m1-skeleton.patch` — verified to apply cleanly and produce a working `please.exe` +- [x] ~~Non-blocking CI job~~ — overtaken by events. M4 added a *blocking* `build-windows` + job that builds the whole release, which is strictly stronger +- [x] ~~`go1.27.0.windows-amd64` hash in `third_party/go/BUILD`~~ — **not needed.** Go + cross-compiles from the host toolchain; there is no Windows distribution to fetch +- [x] `tools/images/windows_builder/Dockerfile`, added to `tools/images/build.sh` — done in M4 + +### Findings that changed the plan + +1. **Errors are layered, not parallel.** `src/process` is a dependency of nearly everything, + so a single `go build ./...` reports 3 errors and leaves 30 of 51 packages unchecked. + Build packages independently and iterate. +2. **`syscall.Exec` is not a compile blocker** — Windows ships a stub returning `EWINDOWS`. + Same for `Chdir` and the signal constants. Silent runtime failures instead. +3. **New: go-flags breaks Please's label syntax on Windows** (D5). `-tags forceposix` fixes + it. Not in the original plan at all. +4. **`src/output/shell_output.go` is an abstraction leak** the source survey missed. +5. **`path.Dir` in `src/cli/logging.go` is a hard startup blocker**, not a cosmetic bug. +6. **busybox-w64 has a `bash` applet** but **rejects `--noprofile`/`--norc`** — contradicting + the Linux busybox result. `ShellArgs` is mandatory. + +## M1 — OS abstraction layer + +**Exit:** `plz build --arch windows_amd64 //src:please` produces `please.exe` via the real +BUILD-file path (not raw `go build`), and `//src/...` unit tests compile. ✅ **Met.** +Full suite: 837 tests, 835 passed, 2 skipped. The cross-built binary parses labels and runs +cold-cache builds under Wine. + +Design: `01-os-abstraction.md`. `probe/m1-skeleton.patch` is a starting shape — but its +`lock_windows.go` and `kill_windows.go` are deliberately wrong and must be replaced, not +adopted. + +**The compile fixes are ~2 days. The rest of the milestone is the runtime work the compiler +gives no signal for.** Do the cheap fixes first to unblock Wine testing, then the real ones. + +- [x] `src/cli/logging.go` `path.Dir` → `filepath.Dir` (hard startup blocker) +- [x] `.plzconfig_windows_amd64` with `[Plugin "go"] BuildTags = forceposix` (D5) — + `go_binary` has no `tags` param, so this is config, not a BUILD edit +- [x] `src/output/shell_output.go` — leak removed; `process.ShareParentProcessGroup` +- [x] `src/core/lock.go` → `lock_other.go` / `lock_windows.go` (`LockFileEx`), real + implementation; all 12 lock tests pass under Wine +- [x] `process.ExecReplace` + 5 call sites (the 6th, `sandbox_linux.go`, is Linux-only). + Verified under Wine: stdout passthrough, exit codes 0 and 3. Also releases the repo + lock before handing over — on Unix the exec did that implicitly via `O_CLOEXEC` +- [x] `src/process/exec_windows.go` — `CREATE_NEW_PROCESS_GROUP`; job objects in + `kill_windows.go` +- [x] `src/process/kill_windows.go` / `kill_other.go` — Ctrl-Break then `TerminateJobObject` +- [x] Narrow `exec_other.go` from `!linux` to `!linux && !windows` +- [x] `src/clean/clean.go` — `ForkExec` → detached `exec.Command` (`DETACHED_PROCESS`) +- [x] `src/cli/process.go` — signal set and exit-code convention now per-platform +- [x] `Build.Xattrs` defaults false on Windows; `pkg/xattr` needed no build tag +- [x] `.exe`/`PATHEXT` via `fs.ExecutableNames`, wired into `core.LookPath`. + Note `isExecutable`'s `0111` check is only reachable on the FreeBSD path, so it needed + nothing — the design doc over-stated this +- [x] `src/run/run_step.go` — `ExitError.ExitCode()`; `syscall.Chdir` → `os.Chdir` + +**Landed early from M3** (M1 is untestable under Wine without it): platform-specific shell +init args, since busybox rejects `--noprofile`/`--norc`. Note this is a property of the shell +being invoked, not the host — remote execution keeps the full flag set via a new +`process.RemoteBashCommand`. The `[build] Shell`/`ShellArgs` *config* is still M3. + +## M2 — Paths, environment and the `.exe` model + +**Exit:** `//src/core/...` and `//src/fs/...` unit tests pass under Wine, including +`lock_test.go` and config loading. + +Design: `01-os-abstraction.md` (the `.exe` model) and `02-shell-and-build-actions.md` (the +path-format rule). + +- [x] Promote `splitPathList` → `fs.SplitPathList` (done in M1, needed by `LookPath`) +- [x] Remaining raw `":"` splits in `src/core/config.go` (6) and `src/remote/action.go`. + The remote one splits locally but still joins with `":"` for the POSIX worker +- [x] `src/fs/home.go` — `os.UserHomeDir()`; `~` regex built from the platform separators +- [x] `MachineConfigFileName` (ProgramData) and `DefaultPath` (empty on Windows — there is + no equivalent of `/usr/bin` holding build tools) +- [x] `USERPROFILE`, `TEMP`, `TMP` — Windows only, so Unix hashes are untouched +- [x] `src/core/build_target.go` — platform-conditional `SandboxDir` +- [x] `src/fs/copy.go` — symlink privilege fallback (copies the target, warns once) +- [x] `src/fs/fs.go` — `RemoveAll` clears the read-only attribute on files too +- [x] **`glob()` returned nothing at all on Windows** — not on the original list, and fatal. + `patternToMatcher` built the pattern with `filepath.Join` while the walk goes through + `io/fs`, whose paths are always slash-separated. Fixed by using `path` throughout +- [x] The raw `"/"` handling in `src/fs/sort.go` and elsewhere in `glob.go` turns out to be + **correct** for the same reason — `io/fs` paths are always `/`. The design doc was + wrong to flag them +- [x] Forward-slash normalisation in `BuildEnvironment` + a test asserting no `\`. + Confirmed by experiment rather than assumption: `echo` and `printf '%s'` round-trip a + backslash path unharmed, but `sed -e "s#x#$TMP_DIR#"` turned `\tmp` into a literal tab + and ate the rest — and the cc rules build their link line with `sed` + +## M3 — Build actions and the bundled shell ✅ + +**Exit:** a `genrule` with `cmd = "cat $SRCS | sort > $OUT"` builds under `plz.exe` on Wine. +**Met**, and this time through config and packaging rather than by hand: nothing was placed on +the PATH and no file was renamed to `bash.exe`. The repo has no `[build] shell` line at all; +`plz.exe` finds the `busybox.exe` sitting next to it and runs the action through it. + +Design: `02-shell-and-build-actions.md`. + +- [x] `[build] Shell` / `ShellArgs` config. Defaults are per-platform, from + `process.DefaultShell` / `DefaultShellArgs`: `bash` with `--noprofile --norc` on Unix, + `busybox` with `bash` (the applet name) on Windows +- [x] `src/process/process.go`, `src/run/run_step.go` — `BashCommand` is now a method on + `Executor`, which carries the shell. `RemoteBashCommand` is untouched and still hardcodes + the full flag set, because the remote worker is a real bash whatever we are running on +- [x] `src/cache/cmd_cache.go` — both `sh -c` sites use the configured shell +- [x] `src/output/shell_output.go` — `plz build --shell` had a third hardcoded shell, not on + the original list. It gets `Executor.InteractiveShellCommand`, which is the same shell + without `-e`/`-u` +- [x] Vendor `busybox.exe` (`remote_file`, pinned hash, GPL-2.0 noted) +- [x] Add to `//package:installed_files` under `is_platform(os = "windows")` +- [x] Applet and flag audit against busybox-w64 — done in M0, see + `02-shell-and-build-actions.md` +- [x] Gate the `xz -zc` tarball rule to Linux (busybox `xz` is decompress-only — re-verified + against busybox-w64 1.38.0, which is also decompress-only despite listing the applet). + `tarball(xzip = True)` now fails at parse time on Windows, and `package/BUILD` defines + the two xz tarballs only where they can be built +- [x] `plz hash //...` unchanged on Linux — verified by hashing the same tree with the old and + new binaries, which agree exactly. The config addition is invisible to the hash because + `Configuration.Hash` covers only `Build.Lang`, `Build.Nonce`, the rejected licences and + the build environment + +**Landed early from M4** (`//package:installed_files` does not build for Windows without it): +`please_sandbox` is gated off Windows. It is built on Linux namespaces, so there was never +anything to ship there, and MinGW rejects `sandbox.c` outright. + +### Findings + +1. **Resolving the shell on `$PATH` alone would have made the bundling pointless.** Nothing + puts Please's install directory on the user's PATH on Windows, so a default of `busybox` + would never have been found. `resolveShell` (`src/core/state.go`) keeps the old behaviour + for a shell that is on the PATH and falls back to the *build* path — which already has + `Please.Location` prepended — only when it is not. Verified both ways under Wine: with + `busybox.exe` beside `plz.exe` the build works with no configuration; with it moved away + the build fails with `exec: "busybox": executable file not found in %PATH%`. +2. **The `busybox bash` applet form behaves exactly like the `bash.exe` copy M0 tested.** + Re-verified under Wine: `-e` stops at the first failure (exit 1), `-u` rejects an unset + variable (exit 2), `-o pipefail` propagates a failure from the left of a pipe (exit 1). + So `ShellArgs = bash` costs nothing over renaming the binary, and avoids installing a file + called `bash.exe` that would shadow a user's real bash. +3. **A repeatable config key still cannot be cleared by assigning it empty** — the M5 trap, + met again. `ShellArgs = ` yields `[""]`, so an empty argument would have been passed + through to the shell. `ShellArgs` drops empty entries, which is what lets a user turn the + shell's arguments off entirely. +4. **Bundling busybox needed a licence-policy change.** `.plzconfig` rejected `GPL-2.0`, so + `//third_party/binary:busybox` failed to build before it was ever run. Accepted with a + comment: Please execs busybox rather than linking it, so they are separately distributed + works, but the release has to carry the licence and a pointer to the source. +5. **`plz run` had the same bare-filename bug as `toolPath`** (M6 finding 2), one layer up: + `run_step.go` decided "does this need looking up on the PATH?" by searching for `/` only, + so an entry-point path built with `filepath.Join` would have been sent to `LookPath`. Now + checks `filepath.Separator` too. + +## M4 — Release pipeline 🟡 + +**Exit:** `plz build --arch windows_amd64 //package:release_files` on Linux CI produces a +signed `windows_amd64/` folder. The command **passes locally**; what is left is the arcat +release it depends on for plugins, and running it on CI for real. + +Design: `04-release-and-ci.md`. + +- [ ] **arcat for `windows_amd64`** — publish the release, then add its hash to + `src/parse/internal_package.go`. **Upgraded in severity:** it blocks `plugin_repo`, and + every language plugin is delivered that way, so no plugin can load on Windows without + it. `cc_library` needs it again for `.a` archives. Simple genrules and parsing work + without it, which is why this was first recorded as minor. + Good news: arcat is pure Go with no syscall/cgo, cross-compiles to PE32+, and both + `arcat x` and `arcat ar -r` verified working under Wine. Its `go.mod` says `go 1.17` + while the code uses generics, so it fails to build on *any* platform with a modern + toolchain — a one-line upstream fix, unrelated to Windows. + **This is the only thing between here and the exit criterion**, and it needs someone with + push access to the arcat repo. Confirmed still true: the v1.3.1 release has assets for + darwin, freebsd and linux only. + + **What has been done instead is to stop it being a wall.** Generating the internal + package used to fail outright on any platform with no published arcat, which stopped + everything rather than only the things that need one. The arcat rule is simply left out + now, so the rest of `//_please` still works, and `plz` warns once at startup that anything + needing arcat — including loading a plugin — will fail unless `[build] arcattool` points + at a build of your own. That is the difference between "Windows cannot parse anything" and + "supply this one binary yourself". +- [x] `.plzconfig_windows_amd64` — landed early in M1 (needed for `forceposix`) +- [x] `package/BUILD` — gate `please_sandbox` on `is_platform(os = "linux")` — done in M3 +- [x] `package/BUILD` — `.zip` release target, built with `arcat zip` on the Linux release + box. The two xz tarballs are replaced by it on Windows rather than added to +- [x] `plz.cmd` shim instead of the `ln -sf please plz` symlink +- [x] `src/update/update.go` — `linkFile` is now per-platform. Windows hard-links instead of + symlinking, and renames a file it cannot replace out of the way to `.stale`, which the + next run sweeps up in `clean()` +- [x] `pleasew.ps1` + `src/assets/BUILD` + root `BUILD`. **Not executed anywhere yet** — + there is no PowerShell on the Linux host, so it has been reviewed but not run +- [x] `.circleci/config.yml` — `build-windows` job, workflow entry, `release-gs` requires +- [x] `.circleci/release.sh` — `release_folder … windows_amd64/$VERSION` +- [x] `tools/misc/gen_release.py` — `_arch()` windows branch, and `.zip` added to the + content-type table +- [x] `tools/images/windows_builder/Dockerfile`, added to `tools/images/build.sh` — the M0 + item. The image tag in `config.yml` is dated `20260910` and **the image has to be built + and pushed before that job can run** + +### What the release turned up + +1. **The `.exe` suffix had to be asked for per target.** `go_binary` names the output after + the rule, so `//src:please` produced a file called `please` for Windows, which cmd will + not run and `LookPath` will not find. `out = "please.exe" if is_platform(...)` fixes it + where it matters today; the general fix belongs in the go plugin (M8). +2. **The whole packaging path is verified end to end under Wine.** Extract the zip as a user + would, run `plz.cmd`, and a genrule builds: the shim finds `please.exe`, which finds + `busybox.exe` beside it with no configuration at all. `query alltargets //...` also works, + which is the `forceposix` smoke test the risk register asked for. +3. **`plz init` now writes `pleasew.ps1` as well as `pleasew`**, on every platform. A repo is + often worked on from more than one, and picking by host would give a Linux developer no way + to set one up for their Windows colleagues. +4. **The release artifact names carry no extension and that is correct.** `please_` + is a URL key, not a filename; the updater downloads it and writes it as `please.exe` + locally. Only the archive members need the suffix. + +## Working on the plugins locally + +All four plugins are separate repos we have no push access to, so they are cloned into +`~/code/-rules` on a `windows` branch each, branched at the tag `plugins/BUILD` pins. + +`plugins/BUILD` takes each checkout path from a `[buildconfig]` key, so switching is a +gitignored `.plzconfig.local` and nothing else: + +```ini +[buildconfig] +go-rules-path = /home/peter/code/go-rules +``` + +Delete that file and you are back on the pinned downloads. Verified both directions. + +Three things learned wiring this up: + +- **`local_repository` is not usable** for this. It omits `plugin = True`, so the subrepo + registers as `plugins/go` rather than `go` and `///go//...` never resolves. Call the + `subrepo()` builtin directly. +- **A subrepo `path` outside the repo root is fine.** It is used verbatim with no containment + check, so no symlink is needed. +- **The local definition has to replace the download, not sit beside it.** The first attempt put + the `subrepo()` calls in a separate untracked package and pointed `[Plugin "go"] Target` at it. + That builds fine until anything parses `plugins/BUILD` as well — `plz test //...` does — and + then dies with *"Found multiple definitions for subrepo 'go'"*. Hence the conditional in the + one file, which costs a few tracked lines but cannot conflict with itself. + +Expect every hash to change when you flip, since the full source path goes into the digest. + +## M5 — C++ on Windows (workstream B) + +**Exit:** `plz build --arch windows_amd64 //test/...` in cc-rules produces PE32+ `.exe` and +`.dll`. + +Design: `03-cc-toolchain.md`. Repo: `please-build/cc-rules`. + +- [x] **D1 confirmed.** Both a WinLibs 16.2.0 and an Ubuntu 13 MinGW match the existing GCC + and GNU ld matchers; the Clang matcher correctly does not. No new matchers needed +- [x] `build_defs/arch.build_defs` — add `windows_amd64` (gates the plugin's own release, + not its use) +- [x] `cc_binary` / `cc_test` → `.exe`; `cc_shared_object` → `.dll`. **Must be a function, + not a module-level constant** — subincluded `CONFIG.OS` reflects the host at module level +- [x] A `cc_library` + `cc_binary` + `cc_shared_object` triple builds and `prog.exe` runs + under Wine, linking the static lib correctly +- [x] Drop `-fPIC` and `-Wl,--build-id=none` for Windows — both were passed and neither + broke the link, so this is noise reduction rather than a blocker +- [x] `DefaultLdFlags` → `-lpthread`. Only `-ldl` was wrong. Note a repeatable config key + **cannot be cleared by assigning empty** — that yields `[""]`, which becomes a bare + `-Wl,` and the linker rejects it. + + **It was set that way anyway**, in *this* repo's `.plzconfig_windows_amd64`, and stayed + broken because nothing here built a C++ target for Windows until one was added. The + platform default now comes from the plugin's build defs, where `CONFIG.OS` is the target; + the plugin's own `.plzconfig_windows_amd64` never applied to anyone using it as a plugin +- [x] **`cc_shared_object` can be linked against.** Windows resolves a DLL's symbols through an + import library rather than through the DLL, so `-l` had nothing to find. The link + now writes one with `--out-implib`, named after the output — `lib.dll.a` for the + default `lib.dll`, which is what `-l` looks for. + + The note in the rule said a second output was impossible, because the shared link command + names its output `$OUT` and that is unset on a multi-output rule. True of `outs`, not of + `optional_outs`, which don't count towards it. `//test/windows:dll_test` builds the pair, + links one against the other and runs it under Wine; taking the DLL away makes it exit 53, + so the linkage is genuinely dynamic. Windows has no rpath, so the DLL has to sit beside + the binary — which is what the test rule's data does +- [ ] `please_cc` `execvp_windows.go` (needed for native Windows, not for Axis 2) +- [x] Parse-time error when `pkg_config_libs` is used on Windows, naming the rule that asked +- [ ] MinGW cross-compile job in `plugin_test_cc.yaml` +- [ ] **`please_cc` needs a `windows_amd64` release.** `tools/BUILD` fetches it as a prebuilt + binary with a pinned hash per platform. Not a blocker under Axis 2, where tools build for + the Linux host, but required for a native Windows plz +- [x] **`cc_test` works on Windows.** Recorded as blocked on `UnitTest++` needing its `Win32/` + sources; that was never true. The sources were selected and then compiled with the host + toolchain, because a target inside a plugin does not see the using repo's plugin config. + One portability fix went with it: the test main called `unsetenv`, which Windows has no + such function for. Guarded by `//test/windows:cc_test_test` + Blocks `cc_test`, not `cc_library`/`cc_binary` +- [ ] Upstream PR; bump `plugins/BUILD` revision + +## M6 — Linux-hosted verification harness + +**Exit:** one CI job builds and runs a C++ `cc_test` for Windows, from Linux, end to end. + +Design: `05-testing-strategy.md`. + +- [x] Wine test macro for cross-compiled Go test binaries — `test/build_defs/wine.build_defs`, + `wine_go_test` and `wine_plz_test` +- [x] Wine CI job — `test-windows-wine`, blocking, and a third pass in `test.sh` where Wine is + installed. Every `//src/...` package whose tests run there at all: 19 targets, 717 tests, + 787 passing and 7 skipped — **every `//src/...` package**. `//src/build`, `//src/exec` + and `//src/process` are the valuable ones: they run real build actions and real + subprocesses, so they cover the layers that are almost entirely separate code on Windows +- [x] The genrule shell smoke test — plus a `query alltargets //...` test, which is the + `forceposix` guard the risk register asked for +- [x] **The headline end-to-end passes.** `wine plz.exe` extracts the cc plugin with + `arcat.exe`, runs build actions through busybox, identifies the toolchain with + `please_cc.exe`, compiles and links with MinGW `g++.exe`, and the resulting `hello.exe` + runs and prints correctly. Every component in that chain is a Windows binary. + + Two environmental caveats, neither a Please defect: Wine here has no working DNS, so + the plugin zip has to be supplied locally rather than downloaded; and `arcat` is pointed + at a cross-built binary via `[build] arcattool`, since there is no published + `windows_amd64` release yet. Both stand in for release infrastructure, not code. + + Getting there surfaced two real bugs — see the M6 findings below. +- [x] Make the Wine job blocking + +### What running the unit tests under Wine surfaced + +Doing this properly for the first time found **four correctness bugs**, all of the same shape +and none visible on Linux: code handling repo-relative or label-derived paths through +`filepath`, whose separator on Windows is a backslash. M2 recorded the inverse of this lesson +(`io/fs` paths are always `/`, so use `path`) and fixed the producers; these are the consumers +it missed. + +1. **`glob()` crossed package boundaries.** `isBuildFile` called `filepath.Base` on a path from + `io/fs`, so on Windows it compared the whole path against `BUILD` and never matched. No + subpackage was ever detected, and a glob in one package would take files belonging to + another. This is the same function M2 fixed the *pattern* side of. +2. **The initial package was wrong from any subdirectory.** `getRepoRoot` returned it with + backslashes, which are illegal in a package name, so validation failed and Please walked up + until something parsed — usually the repo root. `plz build ...` from `src/core` would have + built the wrong thing silently. +3. **Relative labels didn't parse at all.** `path/to:thingy` became `//current_package\path\to`. +4. **`$(location)`, `$(exe)`, `$(worker)` and tool paths expanded with backslashes** into shell + commands, where a backslash is an escape character. M2 normalised the *environment*, which is + a different path. + +**This overturned an M2 decision.** `02-shell-and-build-actions.md` argued for normalising only +at the environment boundary because it was the smaller change. It isn't: the replacements above +are not environment values, and the existing tests already assumed forward slashes throughout. +`plz-out` paths are now built with `path`, so they are slash-separated on every platform. Win32 +accepts either, and it is a no-op on Unix. + +Extending the job past `core` and `fs` found **four more of the same kind**: + +5. **`output_dirs` produced doubled paths.** `copyOutDir` strips the temp directory off a path + to get an output name, comparing a `filepath.Join` result against a slash-separated + `TmpDir()`. Neither prefix matched, the whole path survived as the output name, and + `moveOutputs` then joined the temp directory onto a path that already contained it. +6. **JS coverage file names were never sanitised.** `sanitiseFileNameDir` compared paths from a + coverage file against `filepath.Dir` of a plz-out directory, so coverage was reported + against absolute build paths instead of source files. +7. **Coverage-by-directory keys came out backslashed**, so they neither read correctly nor + matched anything configured. +8. **`file://` URLs could not name a Windows path.** RFC 8089 puts a slash before the drive + letter, so `file:///C:/foo` arrives as `/C:/foo`, which `filepath.IsAbs` rejects. No + `remote_file` with a local URL could work. + +And widening it again, past `build` to the parser, the query layer and the rest, found three +more — including the worst one so far: + +9. **`join_path` in the BUILD language returned backslashes on Windows.** The BUILD language is + the same language on every platform: its paths become labels, sources and command strings, + and every hash that reaches one would have differed. `subinclude`, `subrepo` names and + Bazel-compatibility `load()` had the same problem. +10. **Completion returned backslashed package names**, which are not valid build labels, so + every completion below the top level was unusable. +11. **`plz query changes` matched no package** for a changed file, because it walked up the + directory tree with `filepath.Dir` and looked the result up as a package name. + +And the last packages found a gap in M3's own work: + +12. **The command cache never got the resolved shell.** `resolveShell` lived in + `executorFromConfig`, so only build actions and tests benefited; `[cache] storecommand` and + `retrievecommand` were handed the bare name and could not find the bundled busybox. It is + now `Configuration.Shell()`, resolved once and shared, with a final fallback to the + directory of the running binary — which is where a bundled shell sits, and unlike the build + path does not depend on `Please.Location` having been resolved yet. +13. **`ShellArgs` was empty for any hand-built configuration.** The defaults for a repeatable + key can only be applied after parsing, or gcfg appends to them rather than replacing, so + anything using `DefaultConfiguration()` directly got none. On Unix that quietly dropped + `--noprofile --norc`; on Windows it dropped the applet name, so the shell did not run at + all. `Configuration.ShellArgs()` supplies the platform default when nothing is set. + +### The job objects from M1, finally under test + +`//src/process` used to be excluded because its tests ran `true`, `false` and `sleep` as bare +argv — programs on the PATH on Unix, applets inside the shell on Windows, so only one spelling +works anywhere. Building the argv through the configured shell instead makes them portable, and +that puts the whole process layer under test on Windows for the first time. + +`TestKillsProcessTree` is new, and covers what process groups on Unix and job objects on Windows +both exist for: when a command times out, what it started has to die with it. Nothing tested +that on **any** platform before. It fails on Linux if the signal goes to the process rather than +the group. + +What the Windows side of it actually proves took some establishing, and the answer is not the +obvious one: + +- Disabling `TerminateJobObject` — the test still passes. +- Disabling the Ctrl-Break path as well — the test still passes. +- Disabling `trackProcessTree`, so there is no job object at all — **the run hangs + indefinitely.** + +So it is `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` that does the work, when the handle is closed on +the way out of `ExecWithTimeout`, rather than either explicit kill. And the failure mode without +it is not a surviving grandchild but a hang: the orphans keep Please's pipes open, so it never +finishes rather than failing. That is worth knowing, because a hang is the harder thing to +diagnose in the field. + +Note the grandchild has to be a separate process to test any of this. busybox implements a +subshell as a thread on Windows, so `( ... ) &` would die with its parent and prove nothing. + +A sweep for the same mistake elsewhere, rather than waiting for a test to find it, turned up +four more places where a path Please *prints* came out backslashed: `plz query outputs`, `plz +query graph`, the paths `plz build` reports, and the entries `plz generate` writes into a +`.gitignore` — the last of which would simply not have matched, since git speaks forward slashes +on every platform. None of these has a test that would notice, so they are worth naming. + +A further kind of finding is recorded rather than fixed: **Go's `exec` on Windows will not run a file +whose name has no extension in `PATHEXT`, even given its full path.** The `wine_go_test` macro +copies each test binary to a `.exe` before running it. The same trap is why `//src:please` +needs `out = "please.exe"` (M4), and it will bite `plz run` on any `go_binary` until the go +plugin names Windows outputs properly (M8). + +Two things about the harness itself, both found by tests failing for reasons that were nothing +to do with Please: + +- **Wine's hosts file has the `localhost` line commented out.** Anything resolving it hangs + until it gives up — three `remote_file` tests each burned 15 seconds. The macro appends the + line to the prefix. +- **`~` resolves inside the shared prefix**, so a test writing to its home directory leaks into + the next run. One of these left a read-only file behind, which on Windows the next run cannot + replace. Tests that write to `~` now set `USERPROFILE` themselves, which is what + `os.UserHomeDir` reads there. + +Three tests are honestly unrunnable rather than fixed: + +- **`TestSymlink` skips on Windows.** Creating a symlink needs Developer Mode, and under Wine + `os.Symlink` reports success and produces a link that cannot even be `Lstat`ed. The testing + strategy predicted Wine would grant the privilege unconditionally and so never exercise the + copy fallback; the reality is worse, and belongs on the M9 agenda. +- **`TestSymlinkedOutputs` and `TestCreatePlzOutGo` skip on Windows**, for the same symlink + reason, and the permission assertions in `TestOutputDirDoubleStar` skip because Windows has + no mode to preserve — Go synthesises one from the read-only attribute. + +### What the end-to-end test surfaced + +1. **No package below the top level parsed on Windows.** `buildFileName` joined with + `filepath.Join` and then called `iofs.Stat`, so it looked for a file whose *name* contained + a backslash. Only the root package worked, because `filepath.Join("", "BUILD")` has no + separator to get wrong — which is exactly why every earlier test missed it. This alone + would have made Windows unusable for any real repo. +2. **`toolPath` prepended `./` to absolute paths**, producing + `./Z:/tmp/.../please_cc.exe`, because it decided "is this a bare filename?" by looking for + `/` only. +3. **`.plzconfig` rejects unquoted backslashes** — `unquoted '\' must be followed by new line + or double quote`. Windows paths in config files must use forward slashes or be quoted. + ~~Worth a note in the user docs.~~ **Fixed instead**: the error now names the file it came + from — it did not before — and says to use forward slashes or quote the value. The parser's + own message gives no hint that a path is even involved. +4. **`DefaultPath` being empty on Windows is load-bearing**, not cosmetic: `ar.exe not found + in path` until `[build] path` is configured. That is the intended design, but it means a + Windows user must configure tool locations before anything builds. **The message now says + so** when the only directory searched was Please's own, which is exactly the + nothing-configured state. Note the check counts non-empty entries: clearing a repeatable key + by assigning it empty yields `[""]`, not an empty list — the same trap as `ShellArgs`, met + for the third time. + +## M7 — Sandboxing parity 🟡 + +- [x] Default `Sandbox.Build`/`Sandbox.Test` false on Windows, with a clear log line. The + defaults were already false — the zero value — so the work was the log line, and it + mattered more than it looks. Setting either on Windows previously produced + `Can't find sandbox tool please_sandbox on the path`, which invites you to install + something that does not exist. It now says sandboxing is not implemented on this platform + and that actions will run without isolation, and does not construct a sandboxing executor +- [ ] `sandbox_windows.go` — Job Objects (reuse M1), restricted token, scrubbed environment +- [x] Document the filesystem-isolation gap. There is no mount-namespace analogue on Windows. + A job object can bound processes and a restricted token can drop privileges, but neither + hides a directory, and the only thing that does is a Windows Container — a dependency far + too large to take on for a build tool. So a Windows sandbox could isolate *processes* but + not the *filesystem*, which is the half that matters most for build hermeticity. That is + why refusing to act on the setting, rather than half-implementing it, is the right shape + until someone has a use for the process half on its own + +Note `resolveOut` already guards its sandbox branch on `runtime.GOOS == "linux"`, so `$OUT` +does not change shape on a platform without a sandbox. `target.Sandbox` is still folded into +the target hash, so a Windows user who turns sandboxing on gets different hashes for no +benefit — which is why refusing to act on the setting, rather than quietly ignoring it, is +the right shape. + +## M8 — Remote execution and plugin parity 🟡 + +- [x] ~~`src/remote/action.go` `translateOS` — add `windows`~~ — **nothing to do.** + `reallyTranslateOS` special-cases only `darwin` → `macos` and passes everything else + through, so `windows` already comes out as `windows`. Recorded rather than changed +- [ ] go plugin — `windows_amd64` arch, `.exe` naming. **Now blocking more than it looks:** + Go's `exec` on Windows will not run a file with no `PATHEXT` extension even given its + full path, so `plz run` on any `go_binary` fails until this lands. `//src:please` and + `//tools/build_langserver` work around it per-target (M4). + + **Measured, and the constraint is narrower than it appears.** Windows itself runs such a + file happily — `os.StartProcess` on the bare path works, and prints its output. It is + only Go's `os/exec` that refuses, in `lookExtensions`, and it refuses even when `Cmd.Path` + is set directly, so there is no way to keep `os/exec` and bypass it. Reimplementing + process handling to avoid that is far worse than naming the output correctly, so the fix + stays with the plugin. + + What is fixed here is the message. `executable file not found in %PATH%` for a file that + is plainly there is baffling; `fs.ExplainUnrunnable` adds that the name has no extension + Windows will run, and what it would need to be called +- [x] **go plugin — `.exe` naming done** in the local clone. `go_binary`, `go_test` and + `go_benchmark` append the suffix from a per-call function. Proof it works: the + `out = "please.exe" if is_platform(...)` workarounds in `src/BUILD.plz` and + `//tools/build_langserver` can be deleted and `please.exe` still comes out with the right + name. **They are deliberately still in the tree**, because this repo pins the unfixed + upstream plugin; drop them in the same change that bumps `plugins/BUILD` +- [x] **go plugin — a `windows_amd64` `please_go` release, published from the fork.** Upstream + publishes five platforms and not Windows, so a native Windows plz could not build a Go + target at all. Only that one architecture is redirected to the fork; everything else + still comes from please-build. The download needs an explicit `out`, because the asset + name carries the version and platform and so has no extension in PATHEXT. The same was + done for `please_cc` and `please_pex` +- [ ] go plugin — `windows_amd64` arch for its own release. `tools/please_go:bootstrap` runs + `go build ... && mv please_go $OUT`, which fails where `go build` writes `please_go.exe`, + and hardcodes `TMPDIR=/tmp`. Native-Windows only +- [x] **shell plugin — `sh_test` and `sh_cmd` done.** Windows has no shebang mechanism, so a + `.sh` is not runnable by name however it is written. `sh_test` hands the script to a shell + explicitly and `sh_cmd` takes its interpreter from a new `shell_tool` plugin config rather + than hardcoding `/bin/sh`. Verified under Wine through the bundled busybox + + **Its Windows default was in the wrong place, and did nothing.** It was set in the + plugin's own `.plzconfig_windows_amd64`, and *a plugin's architecture config is never read + when it is used as a plugin*: `readSubrepoConfig` reads only `.plzconfig` from the + subrepo, and the `.plzconfig_` that `state.ForArch` merges belongs to the repo doing + the building. So it worked in the plugin's own tests, where it *is* that repo, and a + Windows user of the plugin silently got `/bin/sh` — a path Windows does not have. + + Reading the subrepo's arch file would not be enough on its own either: a plugin's own + `[Plugin "x"]` values are not consulted for its config, only `[PluginConfig "x"] + DefaultValue`, and merging that appends rather than replaces, so a scalar default cannot + be overridden by a second file. The platform default now lives in the build defs, where + the rest of the platform handling already is. The python plugin does the same for its + run-time interpreters, for the same reason +- [x] **shell plugin — `sh_binary` done** in the local clone. It wrote a shebang, appended the + script, then appended a zip, and relied on the shebang. The payload was never the + problem, since busybox has `unzip`; only the launching was. It **cannot emit a `.cmd` + alongside** because `plz run` requires a single output, so on Windows the single output + *is* the `.cmd`: a four-line batch preamble with the same zip appended after it. cmd.exe + reads a batch file a line at a time and stops at `exit /b`, so it never reaches the + archive. + + The script is left inside the zip rather than inlined the way the Unix version inlines + it. There is no syntax a batch file and a shell script both ignore — a shebang line + works precisely because it is a comment to the shell — so the preamble unpacks the + payload and hands the script to `busybox sh`. It sources it rather than running it, so + that `$0` is the file the user ran, as it is on Unix where the two are one file. Scripts + find their unpacked dependencies relative to `$0`, and would not otherwise. + + **Two things bit on the way, neither of them about batch files.** Build outputs are + read-only and the zip preserved that, so the second run of an `sh_binary` could not + replace what the first one unpacked — Windows forbids it outright — and went on to run + the stale payload, having complained only on stderr. The rule now zips a *copy* of the + build directory with the modes relaxed, because the originals are hardlinked to + `plz-out` and a `chmod` there would quietly make another target's outputs writable. + + And `wine foo.cmd` is not the same thing as running it: what Wine cannot load as a PE it + hands to the host, so the unfixed `.cmd` — still carrying a Unix shebang — ran under + `/bin/sh` and printed exactly what the test wanted. `//test/windows:sh_binary_test` goes + through `cmd.exe` explicitly, runs it twice in the one directory, and fails without + either fix +- [x] **python plugin — `python_binary` and `python_test` done** in the local clone. A pex is a + static ELF preamble with a zip appended, so a Windows cross-build produced an ELF-prefixed + file that was dead on arrival. Four things were needed, and only one of them was the one + we expected: + + 1. **A Windows preamble**, in Go, cross-compiled and embedded in `please_pex` beside the + native C one; `--os`, defaulting to the build environment's `OS`, picks between them. + It reads the same configuration from the same place in the archive. It is a separate + program rather than a port because Windows has no `exec`: it runs the interpreter as a + child and passes the exit status back, which `_execv` cannot do + 2. **`plz.py` built a regex out of `os.sep`** to match distribution metadata inside the + zip. Zip member names are always `/`-separated, so on Windows that was a backslash, + which the regex compiler read as an escape — every pex died on startup, before any of + its own code ran. The `filepath`-for-`path` mistake again, in Python + 3. **`.pex.exe` naming**, for the same reason `go_binary` needs `.exe` + 4. **A run-time interpreter default of `python` then `py`.** The fallback elsewhere is the + interpreter that compiled the sources, which cross-compiling makes the host's, and + `python3` is a spelling a normal Windows install does not have + + **The two-stage plan in the original note was unnecessary.** It assumed the Python inside + would need work too. It does not: Python skips leading non-zip data, so an ELF-prefixed + pex already imported and ran correctly under Wine once the `os.sep` bug was fixed. Only + the launcher was broken, so only the launcher was replaced + + Measured under Wine by `//test/windows:pex_test`, against the embeddable Python from + python.org: `os.name`, importing the test module out of the zip, reading a data file + beside it, and importing third-party code through the meta path hook. Exit-code + propagation is separately guarded by `//test/windows:pex_exit_code_test`, because a + preamble that always returned 0 would make every failing `python_binary` look fine +- [ ] python plugin — a `please_pex` release carrying the Windows preamble. Until there is one + this repo builds the tool from the plugin's source, through `PexTool` in + `.plzconfig_windows_amd64`. Same blocker as `arcat`: no push access, not a porting problem +- [ ] python plugin — `.pyd` extension modules. `SoImport` writes one to a `NamedTemporaryFile` + and loads it while the handle is still open, which Windows does not allow. Only affects + pexes containing native wheels; none of the tests here do +- [x] **A parse deadlock in Please itself**, found building the python plugin's `please_pex` + from source. Resolving a subrepo declared inside another subrepo makes Please look for + the package that would declare it in the host repo first, and fall back to the subrepo + that asked when it isn't there. Not being there is the ordinary case, so that lookup + swallows the error — and it swallowed the parse claim with it, leaving the next caller + waiting on a parse nobody was going to do. Twelve targets hung with no output at all. + `//test/subrepo/nested_subrepo_probe` guards it; two subrepos are needed, because the + first to look is the one that poisons the lookup + +- [x] `src/watch` — **this was a bug, not a documentation task.** `plz watch` compares the + paths it recorded against the ones fsnotify reports. Ours are slash-separated; fsnotify + on Windows reports backslashes. Nothing matched, so every event was discarded as + belonging to a file we weren't watching, and the watch would simply never fire — silently, + since the mismatch looks exactly like an unrelated file changing. Both sides now go + through `watchKey`. `//test/windows:watch_test` guards it, and fails without the fix + +## M9 — Native Windows CI and GA + +- [x] **GitHub Actions `windows-latest` job — done, and blocking.** A Linux job cross-builds + the twenty-three Windows test binaries with their data, plus the release, and hands both + to a `windows-latest` job that runs them. It builds its own artifacts rather than taking + CircleCI's, because a CircleCI workspace is scoped to one CircleCI run and cannot be read + from Actions; the duplicated cross-compile is the price, and in exchange every pull + request produces a downloadable Windows build. + + It was advisory for exactly one run, to produce a failure list without a red check nobody + had read yet. 800 of the Wine suite's tests now run natively, and + `test/windows/run_native_probes.ps1` also builds a repo with the release, cleans and + rebuilds it five times under a live virus scanner, and builds at a long path +- [x] **Work through the Wine-invisible failures — first pass done.** The first native run + found five things, four of them real bugs, and one of those was not on anyone's list: + every `plz` run outside a repo hung at 100% CPU for ever, because the walk towards the + filesystem root never terminates on Windows. `plz clean` also failed every time, exactly + the `ERROR_SHARING_VIOLATION` the risk register predicted, and on Please's own log file. + + `05-testing-strategy.md` now records what a real machine said against each prediction, + with a date. Two items remain out of reach from a CI step: console behaviour, because a + step's stdout is a pipe so the interactive display never engages, and Ctrl-C, which needs + a console the sender is attached to. Both need a machine with a real session +- [x] **`get_plz.ps1`**, served and signed from the same bucket as `get_plz.sh` and run the + same way: `irm https://get.please.build/get_plz.ps1 | iex` +- [x] **`README.md` and `docs/faq.html`.** The FAQ said Windows was not supported natively; + it now says what is supported, and names the two things that behave differently - no + sandbox, and virus scanners holding files open +- [x] **`docs/milestones/18.0.0.html`** +- [x] **`VERSION` 18.0.0 + `ChangeLog` entry** + +## M10 — The codelabs, replayed on Windows 🟡 + +M9 showed that Please runs on Windows. It said nothing about whether the documentation does, and +nothing had ever executed a line of the codelabs, on any platform. + +- [x] **An extractor that refuses to guess.** `//test/windows/codelab_script` reduces + `docs/codelabs/*.md` to a plan of files, commands and directory changes. A block no rule + can classify is an error, not a silently dropped block, and + `//test/windows/codelab_script/script:script_test` runs it against the real codelabs in + the default Linux pass. That is what stops the check drifting from the published pages +- [x] **`test/windows/codelab_steps.conf`**, for what the Markdown cannot say: `.plzconfig` + fragments to merge rather than overwrite, output shown in a `bash` fence, steps that never + exit. Every stanza carries a reason and pins the text it was decided about. It is kept out + of `docs/` so the codelabs still read as documentation +- [x] **`test/windows/run_codelabs.ps1`**, which hands each command to `pwsh` exactly as written, + with a home of its own per codelab. PASS, FAIL, KNOWN, SKIPPED and BLOCKED, and the same + shrink-only known-failures rule as the unit-test job. Its execution path was exercised on + Linux against a synthetic plan; its answers about the real codelabs come only from Windows +- [x] **A blocking `codelabs` job** in `.github/workflows/windows.yml`, beside `test`, fed a plan + the Linux job built and checked +- [x] **The first native runs, and the known-failures list they produced.** Four entries were + listed ahead of the first run from facts checked directly, and it confirmed them and + added two. It also caught the harness: appending `.plzconfig` fragments repeated `GoTool`, + which a plugin section refuses, so the runner now merges key by key. The second run added + the last entry, a failure that is not Windows at all: the Go codelabs' `third_party/go/BUILD` + drops the `go_stdlib` that `plz init plugin go` generates +- [ ] **What to do about the codelabs that cannot work as written.** Deliberately not decided + here, and no codelab has been edited. `test/windows/codelab_known_failures.txt` is the + record that decision should be taken from + +## Risk register + +| Risk | Impact | Mitigation | +|---|---|---| +| MinGW does not match `please_cc`'s existing regexes | Blocks all of M5 | One-command check, first task in M5 | +| ~~busybox-w64 diverges from Linux busybox~~ | **Materialised, resolved.** `--noprofile`/`--norc` rejected | Audit re-run against busybox-w64 in M0; `ShellArgs` promoted from hedge to requirement | +| ~~go-flags `/` option delimiter breaks label syntax~~ | **Found and resolved in M0** | `-tags forceposix` (D5). Must not regress — it is invisible in Please's own source | +| `go_repo` won't generate Windows-only third-party packages on a Linux host | Any unconditional dep on `x/sys/windows` breaks the normal Linux build | Guard such deps with `is_platform(os = "windows")`; `go_library` filters the `_windows.go` srcs to match | +| Assuming `filepath` is always right on Windows | **Materialised twice.** `glob()` matched nothing (M2), then crossed package boundaries and broke relative labels (M6) | Paths from `io/fs`, build labels, and anything going into a shell command are all `/`-separated: use `path`. The inverse of the `logging.go` bug, where `filepath` was the fix. The Wine unit-test job is the guard | +| BUILD files verified only by `go build` | Real breakage invisible until someone runs `plz` | Always verify through `plz build`, not `go build` — this found 3 bugs in one pass | +| Prebuilt per-platform helper binaries with no Windows release | Blocks plugins (arcat) and native cc builds (please_cc) | Both are pure Go and cross-compile cleanly; the work is publishing releases and recording hashes, not porting | +| A dropped `forceposix` tag silently breaks every label | Total CLI breakage, only visible at runtime | ~~Add a Wine smoke test~~ — done: `//test/windows:label_test` | +| Backslash escaping in shell command strings | Intermittent, hard-to-diagnose build failures | The forward-slash rule in `02-shell-and-build-actions.md`, plus an assertion test | +| `.exe` needs to be a core concept after all | Rework of the M2 decision | Verify `plz run` on a `cc_binary` early in M5, before the rest of M5 depends on it | +| Hash drift invalidates every user's cache | Silent, affects all platforms | `plz hash //...` diff on every M1–M3 PR | +| `ERROR_SHARING_VIOLATION` on real Windows | Invisible until M9 | Listed explicitly in the M9 issue; design `RemoveAll` and the updater defensively now | +| arcat platform gate forgotten | `plz.exe` cannot parse anything, discovered late | Called out as a hard gate in M4; it fails at runtime on Windows, not at build time on Linux | +| `plz init plugin` points at upstream plugins with no Windows tools | Every codelab that installs a plugin fails at its first build | Recorded per step in `codelab_known_failures.txt`. The fix is Windows releases upstream, or `plz init plugin` using the forks; that is a docs and release decision, not taken in M10 | +| The codelab replay interprets a block differently from its prose | The check passes or fails for its own reasons rather than the codelab's | Unclassified blocks are fatal; every stanza in `codelab_steps.conf` pins its text with `matches` and carries its reason | diff --git a/docs/design/windows/07-state-of-play.md b/docs/design/windows/07-state-of-play.md new file mode 100644 index 0000000000..aca8864a31 --- /dev/null +++ b/docs/design/windows/07-state-of-play.md @@ -0,0 +1,192 @@ +# State of Play + +Status: **Living document** · Last updated: 2026-09-12 + +Where the Windows port actually is, and what to pick up next. `06-milestones.md` is the +per-milestone tracker with the reasoning; this is the short version for someone starting cold. + +## What works today + +`please.exe` cross-builds from Linux, runs under Wine, ships busybox as its build shell, and +builds a C++ binary end to end through an entirely Windows toolchain, including a DLL and a +binary linked against it. Python works too: a `python_test` and a `python_binary` both build +for Windows and run there, and so does an `sh_binary`, as a `.cmd` with its payload appended. +The release is a `.zip` containing `please.exe`, `busybox.exe`, `build_langserver.exe` and a +`plz.cmd` shim; extracting it and running `plz.cmd` builds a genrule with no configuration at +all. + +Test coverage on Linux is unchanged and green. Coverage *of Windows behaviour* is 34 targets and +873 tests under Wine, run by a blocking CI job and by `./test.sh` as a third pass. Eight of those +targets only exist when a local plugin checkout is configured — see below. + +**And 809 of those tests now run on a real Windows machine, with no Windows-specific skips +left.** The two that still skip there are skipped on every platform and always were. A blocking GitHub Actions job +cross-builds them on Linux and runs them on `windows-latest`, alongside probes that build a repo +with the release zip, clean and rebuild it five times, and build at a long path. That job is the +only thing anywhere that is not taking Wine's word for it. + +**The codelabs are now replayed there as well, and only one can be followed to its end.** Nothing +had ever executed a codelab on any platform. `using_plugins` runs through; `genrule` gets as far +as its custom tool, a `#!/bin/bash` script Windows cannot run; every codelab that builds Go or +Python stops at its first build, and `github_actions` has nothing to run. The causes are upstream +plugin tools and Puku with no Windows release, a Go 1.20 toolchain requested as a `.tar.gz` that +Windows releases never are, Python absent from the empty default build path, and bash syntax. One +cause is not Windows at all: the Go codelabs write a `third_party/go/BUILD` that drops the +`go_stdlib` `plz init plugin go` now generates. Each is in `test/windows/codelab_known_failures.txt` +with the log line behind it. See Loop D in `05-testing-strategy.md`. + +| # | Milestone | State | +|---|---|---| +| M0–M3, M6 | baseline, OS layer, paths, shell, Wine harness | done | +| M4 | release pipeline | done; `arcat` is built from source rather than downloaded | +| M5 | C++ / cc-rules | done, `cc_test` included | +| M7 | sandboxing | decided against, documented | +| M8 | plugins | go, cc, shell, python all done in local clones | +| M9 | native Windows CI and GA | done — 18.0.0 | +| M10 | The codelabs, replayed on Windows | done; findings recorded, docs decision open | + +## The five repos + +| Repo | Branch | Head | +|---|---|---| +| `~/code/please` | `wine` | merged to `master` on the fork | +| `~/code/go-rules` | `windows` | don't double the `.exe` | +| `~/code/cc-rules` | `windows` | emit an import library | +| `~/code/shell-rules` | `windows` | build an `sh_binary` as a `.cmd` | +| `~/code/python-rules` | `windows` | build a `.pex` Windows can run | + +The plugin clones are branched at the tag `plugins/BUILD` used to pin, not at `master`. There is +no push access to any of the *upstream* repos, so nothing is upstreamed, but all five are pushed +to forks at `PeterNeiss/{please,go-rules,cc-rules,shell-rules,python-rules}`, and `plugins/BUILD` +now downloads the four plugins from there, pinned to commit SHAs. The local checkouts are no +longer wired into anything: `.plzconfig.local` is inert and can be deleted. + +Every Wine test is now unconditional. Eight of them used to exist only when a local checkout was +configured — two pex tests, the DLL test, the `cc_test`, the `sh_binary` test — because no +plugin anyone could download carried the fix each one covers. They run in CI now, on every +change, which is where they were always meant to run. + +## Environment + +- Wine prefix: `/tmp/claude-1000/-home-peter-code-please//scratchpad/wineprefix`. It is + session-scoped, so a new session recreates it with `wineboot --init`; the test macros do this + themselves under `plz-out/wineprefix`. +- MinGW is installed (`x86_64-w64-mingw32-g++`), which is what cross-builds C++ for Windows. +- **`plz` on the PATH is not this repo's Please**, and the difference is not cosmetic: the + installed one predates the parse-deadlock fix, so running the Wine tests with it hangs at + the end of the parse with no error. Build `//src:please` and run `plz-out/bin/src/please`, + or `./test.sh`, which does that itself. `plz install` also settles it. +- `go` is not on the default PATH. Use `export PATH="$PWD/plz-out/bin/third_party/go/toolchain/bin:$PATH"` + before `plz lint` or `./test.sh`. +- The `BUILD` files this repo already had are not `plz fmt` clean. Format only the files you + touch, or you will bury your diff. + +## Pick up here + +In rough order of value. + +1. **Decide what to do about the codelabs.** The codelabs job passes only because every failure is + listed in `test/windows/codelab_known_failures.txt` with its evidence, and that file is the input + to the decision. The largest fixes are not in the prose: `plz init plugin` pointing at plugin + releases that exist for Windows, and Go codelabs that do not delete the stdlib it generates. No + codelab has been edited. +2. **`sh_test` cannot take an `sh_binary` as its `src` on Windows.** It copies whatever it is + given to `.sh` and hands that to a shell, and a `.cmd` is not a shell script. The + plugin's own tests are written that way, so they are the thing to fix it against. The + smallest real functional gap left. +3. **Ctrl-Break is delivered but never verified.** `KillProcess` sends one, waits 30ms, then + terminates the job object. `TestKillsProcessTree` passes natively, but it only asserts a + grandchild died, which terminating the job achieves either way — so the graceful path could + be dead code on Windows and no test would notice. + + Harder than it looks, which is why it is still here. The window is 30ms: `KillProcess` sends + the break, waits that long, then terminates the job regardless. A test that asserts the child + shut down gracefully is racing that timer on a CI machine, and a flaky test in a blocking job + is worse than no test. Either call `killProcessTree` directly and wait generously, which + tests the delivery without the timer, or widen the window and say why. +4. **`.pyd` extension modules in a pex.** + `SoImport` writes one to a `NamedTemporaryFile` and + loads it while the handle is still open, which Windows does not allow. Only bites a pex + containing native wheels. +5. **`plz debug` and `plz cover` on a Windows target** are untested. `plz cover` has one + concrete suspicion against it: coverage paths come back from the Python side with + backslashes in them. Both are unknowns rather than known defects, so the native job is + likely to find them faster than guessing will. + +Nothing is blocked on access any more. `arcat` is built from source in this repo, and +`please_go`, `please_cc` and `please_pex` all have `windows_amd64` releases published from the +forks, so a native Windows `plz` has everything it needs to download. What remains is upstream +adoption, which is a matter of someone merging rather than of permission. + +## Things that will bite you again + +Each of these has already cost time once. + +- **`filepath` is wrong for anything that is a build label, a plz-out path, or destined for a + shell command.** Use `path`. This produced roughly a dozen bugs across the port, including + globs crossing package boundaries and `join_path` in the BUILD language returning backslashes, + which would have changed every hash that reached it. It has a Python dialect too: `os.sep` in + the pex bootstrap, matching zip member names, which are always `/`-separated. +- **In a plugin's build defs, a suffix must come from a function, not a module-level constant.** + At module level `CONFIG.OS` is the host, so a constant passes on Linux and misnames everything + when cross-compiling. Make the function idempotent while you are there: appending `.exe` to a + name that already ends in `.exe` produced `please.exe.exe`, which nothing could find. +- **A plugin's own `.plzconfig_` is never read when it is used as a plugin.** Only + `.plzconfig` is read from the subrepo, and the arch file that gets merged belongs to the repo + doing the building. A platform default set that way works in the plugin's own tests and + nowhere else. Put it in the build defs instead. +- **A repeatable config key cannot be cleared by assigning it empty** — that yields a list of one + empty string. Four separate bugs so far. The most recent sat in this repo's own + `.plzconfig_windows_amd64` for weeks, because nothing here built a C++ target for Windows + until a test did. +- **Go's `os/exec` will not run a file with no `PATHEXT` extension**, even given its full path. + Windows itself is fine with it; `os.StartProcess` proves that. Only the lookup refuses. +- **Never run a cross-built test binary by hand in the source tree.** Under `plz test` they get a + sandboxed temp directory; run from the repo root they operate on the repo. Doing this once + deleted the whole of `test/`. +- **A build output is read-only, and on Windows that means it cannot be replaced at all.** + Unpacking an archive of build outputs over a previous unpacking of itself therefore fails, + and tools tend to report it on stderr and carry on with the stale copy. `sh_binary` hit this; + anything else that unpacks build outputs beside themselves will too. +- **A skip hides a bug better than a missing test does.** Deleting two has now found two real + failures that Wine had passed for months. `plz run` handed `cmd.exe` a forward-slashed path, + which it reads as a switch; and every `link:` label silently became a warning. Both were + behind `runtime.GOOS == "windows"` skips that looked reasonable when they were written. +- **`filepath.Split` does not terminate a walk on Windows.** Trimming the separator off `C:\` + leaves `C:`, and splitting that returns it unchanged, so a loop that stops at an empty string + never stops. Compare each step against the previous one instead. This hung every `plz` run + outside a repo, at 100% CPU, and nothing predicted it. +- **A handle this process holds is still a handle.** Windows will not rename or delete a + directory containing a file anything has open, including us. The log file lives under + `plz-out` by default, which is what `plz clean` deletes. +- **`upload-artifact` drops hidden files** unless `include-hidden-files` is set. A dotfile that + exists in `plz-out` is silently not in the artifact, and nothing on the Linux side can catch + it, because the Wine tests never go through one. +- **`plz-out/pkg` is never refreshed once it exists.** The `hlink:` label goes through + `fs.LinkIfNotExists`, and the destination is named after the version, so rebuilding a release + at the same version leaves the previous bytes there, silently. `plz-out/gen//package/` + always has the real artifact. Affects every platform; cost an hour here, twice. +- **`chmod` in a build directory writes through to `plz-out`.** Inputs are hardlinked in, so + relaxing a mode there silently makes another target's outputs writable. Copy first if the + modes need changing. +- **`wine foo.cmd` does not run it as Windows would.** What Wine cannot load as a PE it hands + to the host, so a `.cmd` that still has a Unix shebang on it runs under `/bin/sh` and passes + the test you wrote to catch exactly that. Go through `cmd.exe` explicitly. +- **`plz update` on Windows fetches only the bare binary, not the zip.** Everything else the + release ships - busybox, arcat, and the plz.cmd shim - stays at the + version it was first installed at, silently, getting staler with each update. Nothing has + ever exercised this. +- **Python under Wine needs its output to be a pipe.** Wine's console emulation hands it handles + it rejects at startup otherwise, and the error — `can't initialize sys standard streams` — reads + like a problem with whatever you were testing. It is not. +- **`plz init plugin ` hands a Windows user plugins that cannot build there.** It writes + `owner = "please-build"`, and upstream `please_go`, `please_pex` and `please_cc` publish no + `windows_amd64` asset. This repo's `plugins/BUILD` uses the forks for exactly that reason, and + every codelab that installs a plugin inherits the problem. It is the single largest cause of + codelab failures and will be rediscovered by anyone who follows the docs. +- **`plz init plugin` asks GitHub's API for the latest tag anonymously.** Shared CI addresses hit + the unauthenticated rate limit, and the failure reads as a plugin that cannot be found. A 403 + from `api.github.com` in the codelabs job is that, not a regression. +- **The Go codelabs predate `plz init plugin go` generating a toolchain and a stdlib.** Their + `third_party/go/BUILD` holds only a `go_toolchain`, so following them replaces the generated + `go_stdlib`, and every Go build then fails to find `//third_party/go:std`, on every platform. diff --git a/docs/design/windows/appendix-baseline-errors.md b/docs/design/windows/appendix-baseline-errors.md new file mode 100644 index 0000000000..0c3907676a --- /dev/null +++ b/docs/design/windows/appendix-baseline-errors.md @@ -0,0 +1,207 @@ +# Appendix — Baseline Compile and Runtime Errors + +Status: **Measured** · Milestone: M0 · Last updated: 2026-09-10 + +Real results, not predictions. Measured against Go 1.27.0 (`GOOS=windows GOARCH=amd64`) at +commit `8cddc25` (Release 17.33.0), with a Linux control build as a baseline. + +**Headline: the port is far closer than the source survey suggested.** `please.exe` compiles, +links, parses BUILD files and executes build actions after ~290 lines of change. The hard +work is not making it build — it is making it correct. + +## Method + +```bash +GOOS=windows GOARCH=amd64 go build ./src/... ./tools/... +``` + +**This is misleading on its own.** Go stops at the first failing package in the dependency +graph, and `src/process` is a dependency of nearly everything. The first run reports 3 errors +in 1 package; 30 of 51 packages simply never get type-checked. + +The errors are **layered, not parallel**. You cannot enumerate them up front — each fix +reveals the next layer. Build each package independently and iterate: + +```bash +go list ./src/... ./tools/... > pkgs.txt +while read -r p; do GOOS=windows GOARCH=amd64 go build -o /dev/null "$p"; done < pkgs.txt +``` + +Plan M1 as an iterative loop, not as a checklist derived from a single error dump. + +## Compile blockers — the complete set + +Four layers, five sites, four packages. That is all. + +| Layer | Package | Site | Error | +|---|---|---|---| +| 1 | `src/process` | `exec_other.go:17,18` | `unknown field Setpgid / Foreground in syscall.SysProcAttr` | +| 1 | `src/process` | `process.go:206` | `undefined: syscall.Kill` | +| 2 | `src/core` | `lock.go` ×10 | `undefined: syscall.Flock`, `LOCK_SH/EX/UN/NB` | +| 3 | `src/clean` | `clean.go:96` | `undefined: syscall.ForkExec` | +| 3 | `src/output` | `shell_output.go:467` | `cmd.SysProcAttr.Setpgid undefined` | + +After layer 3, **every package compiles and `./src` links to a valid PE32+ binary.** + +``` +please.exe: PE32+ executable (console) x86-64, for MS Windows, 16 sections +``` + +### Layer 3 contains a site the source survey missed + +`src/output/shell_output.go:467`: + +```go +cmd := state.ProcessExecutor.ExecCommand(...) +// TODO(jpoole): Read the docs. Attaching stdin and out doesn't seem to work with this. +cmd.SysProcAttr.Setpgid = false +``` + +A caller **outside** `src/process` reaching into platform-specific process attributes. This is +an abstraction leak, and the fix is not a build tag here — it is to expose the intent from +`src/process` (e.g. `process.ClearProcessGroup(cmd)`, or a parameter on `ExecCommand`) so the +platform detail stays in one package. Worth auditing for other instances during M1. + +## Corrections to the source survey + +Predictions that were **wrong**, and why. All three arise from the same mistake: assuming +"Unix-only API" means "does not compile on Windows". + +### `syscall.Exec` is not a compile blocker + +Go's `syscall/exec_windows.go` defines: + +```go +func Exec(argv0 string, argv []string, envv []string) (err error) { + return EWINDOWS +} +``` + +It **compiles** and fails at **runtime**. All five call sites (`src/please.go`, +`src/run/run_step.go`, `src/tool/tool.go`, `src/update/update.go`, +`tools/please_shim/main.go`) build cleanly. + +This is *more* dangerous, not less: `plz run`, `plz tool`, `plz update`, `plz op` and the shim +will build, ship, and then fail at runtime with an opaque *"not supported by windows"*. The +`process.ExecReplace` work in `01-os-abstraction.md` is still required — it just cannot be +driven by the compiler. It needs tests. + +`syscall.Chdir` likewise exists on Windows. + +### Signal constants are not a compile blocker + +`syscall.SIGHUP`, `SIGQUIT` and `SIGABRT` are all defined in `syscall/types_windows.go`. +`src/cli/process.go` compiles unchanged. Narrowing the `signal.Notify` set is a *correctness* +change (Windows delivers only `os.Interrupt` and a synthesised `SIGTERM`), not a build fix. + +### `pkg/xattr` needs no build tag + +The module ships `xattr_unsupported.go`. It compiles for Windows and returns `ENOTSUP`. +The M0 open question is resolved: **defaulting `Build.Xattrs = false` on Windows is +sufficient.** No `attr_unix.go`/`attr_windows.go` split needed. + +### `syscall.ForkExec` genuinely is absent + +`src/clean` is the one prediction that held exactly. + +## Runtime findings + +Compiling is not the interesting part. These were found by running the binary under Wine 9.0 +and are ordered as encountered — each one blocks everything after it. + +### R1 — go-flags parses `//pkg:target` as a flag *(new; not in the original plan)* + +```console +$ wine please.exe query alltargets //... +CRITICAL: unknown flag `/...' +``` + +`github.com/thought-machine/go-flags` ships `optstyle_windows.go`: + +```go +// Windows uses a front slash for both short and long options. Also it uses +// a colon for name/argument delimter. +const ( + defaultShortOptDelimiter = '/' + defaultLongOptDelimiter = "/" + defaultNameArgDelimiter = ':' +) +``` + +**This collides with Please's entire label syntax.** `//pkg:target` parses as option `/pkg` +with argument `target`; `//...` is an unknown flag. Every command taking a build label — which +is nearly all of them — is broken. + +**Fix:** the file is guarded `// +build !forceposix`. Build with `-tags forceposix`: + +```bash +go build -tags forceposix ./src +``` + +Verified: label parsing works completely with the tag. + +Note it is **not** a per-target BUILD change — `go_binary` has no `tags` parameter. The go +plugin sources build tags from `CONFIG.GO.BUILD_TAGS`, so it goes in +`.plzconfig_windows_amd64` as `[Plugin "go"] BuildTags = forceposix`, where it covers every +go-flags binary built for Windows without touching other platforms. + +### R2 — `path.Dir` on a filesystem path blocks startup + +```console +CRITICAL: Error opening log file: open Z:\...\plz-out\log\build.log: Path not found. +``` + +`src/cli/logging.go:64` uses `path.Dir(logFile)` instead of `filepath.Dir`. Predicted in +`01-os-abstraction.md` as a "genuine bug"; confirmed here as a **hard startup blocker**, not a +cosmetic issue. `plz` cannot run at all until it is fixed. + +### R3 — no shell + +```console +Error building target //:hello: exec: "bash": executable file not found in %PATH% +``` + +Exactly as designed for in M3. Everything upstream of the shell works. + +## What works, verified end to end + +With the four compile fixes, `-tags forceposix`, the `path.Dir` fix, and busybox-w64 on +`%PATH%` as `bash.exe`: + +```console +$ wine please.exe query alltargets //... +//:hello + +$ wine please.exe build //:hello +plz-out\gen\hello.txt + +$ wine please.exe build //:pipeline //:findpipe +plz-out\gen\sorted.txt +plz-out\gen\found.txt +``` + +where `pipeline` is `cat $SRCS | sort > $OUT` and `findpipe` is +`find . -name '*.o' -or -name '*.a' | sort | tr '\n' ','` — the construct the cc rules depend +on. Both produce correct output. + +**This means the parser (`src/parse/asp`), config loading, the build graph, target hashing, +`plz-out` population and build-action execution all already work on Windows.** + +Note the predicted "hard gate" in `src/parse/internal_package.go` (the exhaustive arcat +platform switch) did **not** trigger for parse or for simple genrules. It is only reached when +the `_please` internal package is actually needed. Still required for M4, but it is not the +early blocker the plan implied. + +## Reference artifact + +The throwaway probe patch is at `probe/m1-skeleton.patch` (287 lines, 17 files). + +**It is not an implementation.** `lock_windows.go` returns `nil` — a no-op lock — and +`kill_windows.go` kills only the direct child, not the tree. It exists to prove the layering +and to give M1 a starting shape. Do not ship it. + +## Progress + +| Date | Compile-blocking sites | Notes | +|---|---|---| +| 2026-09-10 | 5 (4 packages, 4 layers) | Baseline. `please.exe` links, parses and builds after ~290 lines of probe changes. | diff --git a/docs/design/windows/probe/README.md b/docs/design/windows/probe/README.md new file mode 100644 index 0000000000..5a478d0af7 --- /dev/null +++ b/docs/design/windows/probe/README.md @@ -0,0 +1,33 @@ +# Probe artifacts + +Throwaway material from the M0 investigation. **Not implementations — do not ship.** + +- `m1-skeleton.patch` — the minimal set of changes that makes `plz` compile, link, parse and + build under Windows. 17 files, 287 lines. Deliberately incorrect in places: + `lock_windows.go` returns `nil` (a no-op lock, which would corrupt concurrent builds) and + `kill_windows.go` kills only the direct child rather than the process tree. + +Its value is that it proves the layering described in `../appendix-baseline-errors.md` and +gives M1 a starting shape. + +Apply with `git apply docs/design/windows/probe/m1-skeleton.patch` from the repo root, then +build with `-tags forceposix` (see R1 in the appendix). + +## Workstream B (`please-build/cc-rules`) + +- `cc-rules-windows.patch` — the M5 changes to `build_defs/cc.build_defs`, against v0.7.3. + Unlike the M1 skeleton these are real and were verified end to end, but they live in + another repo, so they are recorded here until they are upstreamed. +- `cc-rules.plzconfig_windows_amd64` — the arch config used to test them. The toolchain paths + assume `g++-mingw-w64-x86-64` is installed. + +Reproduce with: + +```bash +git clone --branch v0.7.3 https://github.com/please-build/cc-rules +cd cc-rules +git apply /path/to/cc-rules-windows.patch +cp /path/to/cc-rules.plzconfig_windows_amd64 .plzconfig_windows_amd64 +plz build --arch windows_amd64 //test/binary:test_binary +file plz-out/bin/windows_amd64/test/binary/test_binary.exe # PE32+ executable +``` diff --git a/docs/design/windows/probe/cc-rules-windows.patch b/docs/design/windows/probe/cc-rules-windows.patch new file mode 100644 index 0000000000..5c6b9b1973 --- /dev/null +++ b/docs/design/windows/probe/cc-rules-windows.patch @@ -0,0 +1,56 @@ +diff --git a/build_defs/cc.build_defs b/build_defs/cc.build_defs +index bfdfaa6..92db3cd 100644 +--- a/build_defs/cc.build_defs ++++ b/build_defs/cc.build_defs +@@ -26,6 +26,23 @@ _ACTION_FLAGS = [ + + _COVERAGE_FLAGS = ["--coverage", "-fprofile-dir=."] + ++def _exe_suffix(): ++ """Returns the filename suffix for an executable on the target platform. ++ ++ Windows decides what is executable by extension, and the toolchain appends .exe to a -o ++ name that has none, so the rule has to declare the name the compiler will actually write. ++ ++ N.B. this has to be evaluated per call rather than once at module level: these build defs ++ are subincluded, and CONFIG.OS there reflects the host until a rule is actually being ++ instantiated for the target architecture. ++ """ ++ return ".exe" if CONFIG.OS == "windows" else "" ++ ++ ++def _so_suffix(): ++ """Returns the filename suffix for a shared library on the target platform.""" ++ return ".dll" if CONFIG.OS == "windows" else ".so" ++ + # Clang >= 16 just require C++20 features to be enabled in order to enable modules, while older versions explicitly + # require the -fmodules-ts flag. + _MODULE_FLAGS = ["""'{{ clang && clang < 16 ? "-fmodules-ts" : "-std=c++20" }}'"""] +@@ -436,7 +453,8 @@ def cc_shared_object(name:str, srcs:list=[], hdrs:list=[], out:str='', optional_ + } + cmds, tools = _binary_cmds(_c, linker_flags, pkg_config_libs, strip, shared=True, test=False) + if not out: +- out = f'{name}.so' if name.startswith('lib') else f'lib{name}.so' ++ so = _so_suffix() ++ out = f'{name}{so}' if name.startswith('lib') else f'lib{name}{so}' + return build_rule( + name=name, + srcs={'srcs': srcs, 'hdrs': hdrs}, +@@ -584,7 +602,7 @@ def cc_binary(name:str, srcs:list=[], hdrs:list=[], private_hdrs:list=[], + deps += [lib_rule] + return build_rule( + name=name, +- outs=[name], ++ outs=[name + _exe_suffix()], + deps=deps, + visibility=visibility, + cmd=cmds, +@@ -675,7 +693,7 @@ def cc_test(name:str, srcs:list=[], hdrs:list=[], compiler_flags:list&cflags&cop + + return build_rule( + name=name, +- outs=[name], ++ outs=[name + _exe_suffix()], + deps=deps, + data=data, + visibility=visibility, diff --git a/docs/design/windows/probe/cc-rules.plzconfig_windows_amd64 b/docs/design/windows/probe/cc-rules.plzconfig_windows_amd64 new file mode 100644 index 0000000000..176f0dcbe4 --- /dev/null +++ b/docs/design/windows/probe/cc-rules.plzconfig_windows_amd64 @@ -0,0 +1,5 @@ +[Plugin "cc"] +cctool = x86_64-w64-mingw32-gcc +cpptool = x86_64-w64-mingw32-g++ +artool = x86_64-w64-mingw32-ar +defaultldflags = -lpthread diff --git a/docs/design/windows/probe/m1-skeleton.patch b/docs/design/windows/probe/m1-skeleton.patch new file mode 100644 index 0000000000..536e21a2f5 --- /dev/null +++ b/docs/design/windows/probe/m1-skeleton.patch @@ -0,0 +1,287 @@ +--- a/src/process/exec_other.go ++++ b/src/process/exec_other.go +@@ -1,5 +1,5 @@ +-//go:build !linux +-// +build !linux ++//go:build !linux && !windows ++// +build !linux,!windows + + package process + +--- a/src/process/exec_windows.go ++++ b/src/process/exec_windows.go +@@ -0,0 +1,7 @@ ++package process ++ ++import "os/exec" ++ ++func (e *Executor) ExecCommand(sandbox SandboxConfig, foreground bool, command string, args ...string) *exec.Cmd { ++ return exec.Command(command, args...) ++} +--- a/src/process/kill_unix.go ++++ b/src/process/kill_unix.go +@@ -0,0 +1,12 @@ ++//go:build !windows ++ ++package process ++ ++import ( ++ "os/exec" ++ "syscall" ++) ++ ++func killGroup(cmd *exec.Cmd, sig syscall.Signal) { ++ syscall.Kill(-cmd.Process.Pid, sig) ++} +--- a/src/process/kill_windows.go ++++ b/src/process/kill_windows.go +@@ -0,0 +1,10 @@ ++package process ++ ++import ( ++ "os/exec" ++ "syscall" ++) ++ ++func killGroup(cmd *exec.Cmd, sig syscall.Signal) { ++ _ = cmd.Process.Kill() ++} +--- a/src/process/pgroup_unix.go ++++ b/src/process/pgroup_unix.go +@@ -0,0 +1,7 @@ ++//go:build !windows ++ ++package process ++ ++import "os/exec" ++ ++func ClearProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr.Setpgid = false } +--- a/src/process/pgroup_windows.go ++++ b/src/process/pgroup_windows.go +@@ -0,0 +1,5 @@ ++package process ++ ++import "os/exec" ++ ++func ClearProcessGroup(cmd *exec.Cmd) {} +--- a/src/process/shellargs_unix.go ++++ b/src/process/shellargs_unix.go +@@ -0,0 +1,5 @@ ++//go:build !windows ++ ++package process ++ ++var shellArgs = []string{"--noprofile", "--norc"} +--- a/src/process/shellargs_windows.go ++++ b/src/process/shellargs_windows.go +@@ -0,0 +1,4 @@ ++package process ++ ++// busybox-w64's bash applet rejects --noprofile/--norc but honours -e/-u/-o pipefail. ++var shellArgs []string +--- a/src/process/process.go ++++ b/src/process/process.go +@@ -203,7 +203,7 @@ + // This is a bit of a fiddle. We want to wait for the process to exit but only for just so + // long (we do not want to get hung up if it ignores our SIGTERM). + log.Debug("Sending signal %s to -%d", sig, cmd.Process.Pid) +- syscall.Kill(-cmd.Process.Pid, sig) // Kill the group - we always set one in ExecCommand. ++ killGroup(cmd, sig) + + select { + case <-ch: +@@ -293,8 +293,10 @@ + + // BashCommand returns the command that we'd use to execute a subprocess in a shell with. + func BashCommand(binary, command string, exitOnError bool) []string { ++ argv := append([]string{binary}, shellArgs...) + if exitOnError { +- return []string{binary, "--noprofile", "--norc", "-e", "-u", "-o", "pipefail", "-c", command} ++ argv = append(argv, "-e") + } +- return []string{binary, "--noprofile", "--norc", "-u", "-o", "pipefail", "-c", command} ++ argv = append(argv, "-u", "-o", "pipefail", "-c", command) ++ return argv + } +--- a/src/core/lock.go ++++ b/src/core/lock.go +@@ -6,7 +6,6 @@ + "fmt" + "os" + "strconv" +- "syscall" + + "github.com/thought-machine/please/src/fs" + ) +@@ -25,7 +24,7 @@ + // AcquireSharedRepoLock acquires a shared lock on the repo lock file. The file descriptor is reused if already opened + // allowing its lock mode to be replaced. Dies if the lock cannot be successfully acquired. + func AcquireSharedRepoLock() { +- if err := acquireRepoLock(syscall.LOCK_SH); err != nil { ++ if err := acquireRepoLock(plzLOCK_SH); err != nil { + log.Fatal(err) + } + } +@@ -33,7 +32,7 @@ + // AcquireExclusiveRepoLock acquires an exclusive lock on the repo lock file. The file descriptor is reused if already opened + // allowing its lock mode to be replaced. Dies if the lock cannot be successfully acquired. + func AcquireExclusiveRepoLock() { +- if err := acquireRepoLock(syscall.LOCK_EX); err != nil { ++ if err := acquireRepoLock(plzLOCK_EX); err != nil { + log.Fatal(err) + } + } +@@ -75,13 +74,13 @@ + + // AcquireExclusiveFileLock opens a file to acquire an exclusive lock. + func AcquireExclusiveFileLock(filePath string) (*os.File, error) { +- return acquireOpenFileLock(filePath, syscall.LOCK_EX) ++ return acquireOpenFileLock(filePath, plzLOCK_EX) + } + + // AcquireSharedFileLock opens a file to acquire a shared lock. + // Multiple of these can be held at once, but not concurrently with an exclusive lock (ala a RWMutex or similar). + func AcquireSharedFileLock(filePath string) (*os.File, error) { +- return acquireOpenFileLock(filePath, syscall.LOCK_SH) ++ return acquireOpenFileLock(filePath, plzLOCK_SH) + } + + // Base function that allows to set up different lock modes and facilitate testing. +@@ -105,7 +104,7 @@ + return + } + +- if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil { ++ if err := plzFlock(int(file.Fd()), plzLOCK_UN); err != nil { + log.Errorf("Failed to release lock for %s: %s", file.Name(), err) // No point making this fatal really + } + if err := file.Close(); err != nil { +@@ -118,7 +117,7 @@ + func acquireFileLock(file *os.File, how int, levelLog logFunc) error { + // Try a non-blocking acquire first so we can warn the user if we're waiting. + log.Debug("Attempting to acquire lock for %s...", file.Name()) +- err := syscall.Flock(int(file.Fd()), how|syscall.LOCK_NB) ++ err := plzFlock(int(file.Fd()), how|plzLOCK_NB) + if err != nil { + pid, err := os.ReadFile(file.Name()) + if err == nil && len(pid) > 0 { +@@ -127,14 +126,14 @@ + levelLog("Looks like another process has already acquired the lock for %s. Waiting for it to finish...", file.Name()) + } + +- if err := syscall.Flock(int(file.Fd()), how); err != nil { ++ if err := plzFlock(int(file.Fd()), how); err != nil { + return fmt.Errorf("Failed to acquire lock for %s: %w", file.Name(), err) + } + } + log.Debug("Acquired lock for %s", file.Name()) + + // Record content, only if we have an exclusive lock. +- if how&syscall.LOCK_EX != 0 { ++ if how&plzLOCK_EX != 0 { + if err := file.Truncate(0); err == nil { + file.WriteAt([]byte(strconv.Itoa(os.Getpid())), 0) + } +--- a/src/core/lock_unix.go ++++ b/src/core/lock_unix.go +@@ -0,0 +1,14 @@ ++//go:build !windows ++ ++package core ++ ++import "syscall" ++ ++const ( ++ plzLOCK_SH = syscall.LOCK_SH ++ plzLOCK_EX = syscall.LOCK_EX ++ plzLOCK_UN = syscall.LOCK_UN ++ plzLOCK_NB = syscall.LOCK_NB ++) ++ ++func plzFlock(fd, how int) error { return syscall.Flock(fd, how) } +--- a/src/core/lock_windows.go ++++ b/src/core/lock_windows.go +@@ -0,0 +1,10 @@ ++package core ++ ++const ( ++ plzLOCK_SH = 1 ++ plzLOCK_EX = 2 ++ plzLOCK_UN = 8 ++ plzLOCK_NB = 4 ++) ++ ++func plzFlock(fd, how int) error { return nil } +--- a/src/clean/clean.go ++++ b/src/clean/clean.go +@@ -8,7 +8,6 @@ + "fmt" + "os" + "path/filepath" +- "syscall" + + "github.com/thought-machine/please/src/build" + "github.com/thought-machine/please/src/cli/logging" +@@ -93,7 +92,7 @@ + } + // Note that we can't fork() directly and continue running Go code, but ForkExec() works okay, + // so we re-execute ourselves with a specific command that will remove this. +- _, err = syscall.ForkExec(exec, []string{exec, "clean", "--rm", newDir}, nil) ++ err = forkExec(exec, []string{exec, "clean", "--rm", newDir}) + return err + } + +--- a/src/clean/forkexec_unix.go ++++ b/src/clean/forkexec_unix.go +@@ -0,0 +1,10 @@ ++//go:build !windows ++ ++package clean ++ ++import "syscall" ++ ++func forkExec(bin string, argv []string) error { ++ _, err := syscall.ForkExec(bin, argv, nil) ++ return err ++} +--- a/src/clean/forkexec_windows.go ++++ b/src/clean/forkexec_windows.go +@@ -0,0 +1,7 @@ ++package clean ++ ++import "os/exec" ++ ++func forkExec(bin string, argv []string) error { ++ return exec.Command(bin, argv[1:]...).Start() ++} +--- a/src/cli/logging.go ++++ b/src/cli/logging.go +@@ -7,7 +7,7 @@ + "container/list" + "fmt" + "os" +- "path" ++ "path/filepath" + "regexp" + "strings" + "sync" +@@ -61,7 +61,7 @@ + // InitFileLogging initialises an optional logging backend to a file. + func InitFileLogging(logFile string, logFileLevel Verbosity, append bool) { + fileLogLevel = logging.Level(logFileLevel) +- if err := os.MkdirAll(path.Dir(logFile), os.ModeDir|0775); err != nil { ++ if err := os.MkdirAll(filepath.Dir(logFile), os.ModeDir|0775); err != nil { + log.Fatalf("Error creating log file directory: %s", err) + } + flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC +--- a/src/output/shell_output.go ++++ b/src/output/shell_output.go +@@ -464,7 +464,7 @@ + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // TODO(jpoole): Read the docs. Attaching stdin and out doesn't seem to work with this. +- cmd.SysProcAttr.Setpgid = false ++ process.ClearProcessGroup(cmd) + cmd.Run() // Ignore errors, it will typically end by the user killing it somehow. + } + } diff --git a/docs/faq.html b/docs/faq.html index ef1e881f2c..dab34b8046 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -37,9 +37,24 @@

    - Windows is unfortunately not supported natively, since it's just too - different from the Unix environment Please is designed for. It is possible - to run it within + Windows is supported natively on amd64. Please ships busybox as its build + shell there, so a fresh install needs no configuration: extract the release + and run

    plz
    . Building Go, C++ and Python + all work, and the test suite runs on Windows in CI rather than only under + emulation. +

    + +

    + Two differences are worth knowing. There is no build sandbox on Windows, + because it is built on Linux namespaces and there is no equivalent; build + actions and tests see the whole machine. And real-time virus scanning holds + freshly written files open, which shows up as intermittent sharing-violation + errors and slow builds, so excluding your +

    plz-out
    directories is worth doing. +

    + +

    + Running under rel="noopener" >WSL - though. + also works, and is still the better choice if your build depends on Unix + tooling that has no Windows equivalent.

    diff --git a/docs/milestones/18.0.0.html b/docs/milestones/18.0.0.html new file mode 100644 index 0000000000..1fcbe5cddd --- /dev/null +++ b/docs/milestones/18.0.0.html @@ -0,0 +1,64 @@ +

    Please 18.0.0

    + +

    + This release adds native Windows support. You can find the complete changelog + here. +

    + +
    +

    Windows

    +

    + Please now runs natively on Windows, on amd64. Extract the release and run + plz; there is nothing to configure. Windows has no shell that can run a + build action, so the release bundles busybox and + uses it by default. +

    +

    + Install it with: +

    +
    irm https://get.please.build/get_plz.ps1 | iex
    +

    + Building Go, C++ and Python all work. C++ builds through MinGW-w64, including shared libraries, + which Windows links through an import library rather than through the DLL itself. A + sh_binary becomes a .cmd, because Windows has no + shebang mechanism and a batch file is the only kind of script it will run by name. +

    +
    + +
    +

    What to expect

    +

    + Two things behave differently, and neither is a bug we intend to fix soon. +

    +

    + There is no build sandbox. Please's sandbox is built on Linux namespaces and Windows has no + equivalent, so build actions and tests see the whole machine. plz will not + pretend otherwise: sandboxing is off in the default Windows configuration rather than silently + ineffective. +

    +

    + Real-time virus scanning holds freshly written files open, which Windows treats as a reason to + refuse deleting or renaming them. That surfaces as intermittent sharing-violation errors and slower + builds. Excluding your plz-out directories from scanning is worth doing. +

    +

    + Symlinks need Developer Mode or SeCreateSymbolicLinkPrivilege, which an + ordinary account has neither of. Where Please would make one it copies instead, so nothing fails; you + will see a single warning saying so. +

    +
    + +
    +

    How it is tested

    +

    + The Windows build is exercised two ways. A Wine job on Linux runs the cross-built binaries on every + change, which is what made the port developable at all. On top of that, a + windows-latest job runs the same test binaries natively, alongside probes + that build a repository with the release itself, clean and rebuild it repeatedly under a live virus + scanner, and build at a long path. +

    +

    + The native job is the one that counts, and it is blocking. Wine passing is evidence, not proof, and + most of the bugs found late in this work were ones Wine had been passing for months. +

    +
    diff --git a/package/BUILD b/package/BUILD index 97d140ff3b..16cdf7b740 100644 --- a/package/BUILD +++ b/package/BUILD @@ -1,11 +1,37 @@ subinclude("//build_defs:version") +# arcat, built for Windows. Please normally downloads a prebuilt arcat keyed by platform and +# there is no Windows one, so the release carries this instead; [build] arcattool defaults to +# it once it is installed beside please.exe. See docs/design/windows/08-offline-release.md. +# +# Renamed because Windows will not run a file whose name has no extension in PATHEXT, and the +# go plugin names a binary after its rule. cp, rather than asking for a different out, because +# the target is not ours to change. +# +# please_go, please_cc and please_pex are not here, and cannot be: cross-compiling a plugin's +# own tool through ///go//tools/please_go and friends collides on subrepo names, because an arch +# subrepo's name does not carry the subrepo that owns it, so the plugin's third_party/go and +# ours both claim third_party/go/github.com_stretchr_testify@windows_amd64. Until those three +# have windows_amd64 releases to download, a native Windows plz can parse and extract plugins +# but cannot build a Go, C++ or Python target. Cross-building from Linux is unaffected, since +# tools resolve to the host. +if is_platform(os = "windows"): + genrule( + name = "arcat", + srcs = ["///third_party/go/github.com_please-build_arcat//:arcat"], + outs = ["arcat.exe"], + binary = True, + cmd = "cp $SRC $OUT", + ) + filegroup( name = "tools", srcs = [ "//tools/build_langserver", + ] + ([ + # The sandbox is built on Linux namespaces, so there is nothing to ship elsewhere. "//tools/sandbox:please_sandbox", - ], + ] if not is_platform(os = "windows") else []), binary = True, visibility = ["//src:tools"], ) @@ -15,23 +41,61 @@ filegroup( srcs = [ ":tools", "//src:please", - ], + ] + ([ + # Windows has no shell that can run a build action, so we ship one. The default + # [build] shell is 'busybox', which resolves to this once it's installed. + "//third_party/binary:busybox", + # install.sh gets the short name with 'ln -sf please plz'. Symlinks need Developer + # Mode on Windows, so a one-line batch file stands in for it. + ":plz_cmd", + # Windows has no installer and no package manager to carry the instructions, so they + # travel in the zip. + ":install_md", + # Extracting a plugin needs arcat, and there is no arcat release for Windows, so a + # fresh install there cannot get a plugin at all without one of its own. + ":arcat", + ] if is_platform(os = "windows") else []), binary = True, entry_points = { - "please": "please", + "please": "please.exe" if is_platform(os = "windows") else "please", }, labels = ["link:plz-out/please"], visibility = ["PUBLIC"], ) -tarball( - name = "please_tarball_xz", - srcs = [":installed_files"], - out = "please_%s.tar.xz" % VERSION, - subdir = "please", - xzip = True, +filegroup( + name = "plz_cmd", + srcs = ["plz.cmd"], + binary = True, +) + +filegroup( + name = "install_md", + srcs = ["Install.md"], ) +# xz only compresses where there is an xz binary to do it, which excludes Windows - the +# busybox we bundle there decompresses only. Windows gets a .zip in place of the two xz +# tarballs; the gzip one is built everywhere. +XZIP = not is_platform(os = "windows") + +if XZIP: + tarball( + name = "please_tarball_xz", + srcs = [":installed_files"], + out = "please_%s.tar.xz" % VERSION, + subdir = "please", + xzip = True, + ) + + tarball( + name = "please_tools_tarball", + srcs = [":tools"], + out = "please_tools_%s.tar.xz" % VERSION, + subdir = "please_tools", + xzip = True, + ) + tarball( name = "please_tarball", srcs = [":installed_files"], @@ -39,13 +103,20 @@ tarball( subdir = "please", ) -tarball( - name = "please_tools_tarball", - srcs = [":tools"], - out = "please_tools_%s.tar.xz" % VERSION, - subdir = "please_tools", - xzip = True, -) +# Windows has no guaranteed tar, so its release is a zip. This is built on the Linux release +# box like everything else, so it uses the host's arcat, not a Windows one. +if is_platform(os = "windows"): + genrule( + name = "please_zip", + srcs = [":installed_files"], + outs = ["please_%s.zip" % VERSION], + # --rename_dir, rather than --prefix, because entries come out under the package + # directory they were built in and the release wants them at the top of a please/. + cmd = "$TOOL zip --dumb --input package --output $OUT --rename_dir package:please", + tools = [CONFIG.ARCAT_TOOL], + # //test/windows runs the artifact itself, rather than a reconstruction of it. + visibility = ["//test/windows:all"], + ) genrule( name = "please", @@ -69,9 +140,10 @@ filegroup( ":please", ":please_shim", ":please_tarball", + ] + ([ ":please_tarball_xz", ":please_tools_tarball", - ], + ] if XZIP else [":please_zip"]), labels = ["hlink:plz-out/pkg/${OS}_${ARCH}"], ) diff --git a/package/Install.md b/package/Install.md new file mode 100755 index 0000000000..a490dc80d8 --- /dev/null +++ b/package/Install.md @@ -0,0 +1,78 @@ +# Installing Please on Windows + +This is the Windows build of Please. Windows support is new, so read the last two sections +before you rely on it. + +## Install + +1. Extract the zip. It contains a single `please` directory; put that wherever you keep tools, + for example `C:\Tools\please`. Nothing writes to the directory afterwards, so Program Files + is fine too. +2. Add that directory to your `PATH`, so that `plz` works from any repository. +3. Check it: + + ``` + plz --version + ``` + +`plz.cmd` is the entry point, and it does nothing but run `please.exe` beside it. Everywhere +else Please installs `plz` as a symlink; Windows needs Developer Mode for those, so a one-line +batch file stands in. + +There is no installer, no registry key and no service. Uninstalling is deleting the directory. + +## What is in here + +| File | What it is | +|---|---| +| `please.exe` | Please itself | +| `plz.cmd` | the short name you type | +| `busybox.exe` | the shell that build actions run in | +| `build_langserver.exe` | the BUILD-file language server, for editor integration | +| `arcat.exe` | Please's archive tool, used by many built-in rules | + +Some builds also carry the language plugins, as `plugin_*.zip` alongside `please_go.exe`, +`please_cc.exe` and `please_pex.exe`. If `plugin_revisions.txt` is here, yours is one of them, +and that file says exactly which build of each plugin you have. + +**Keep these files together.** Please finds the shell, the archive tool and the bundled plugins +by looking beside its own binary. Copying `please.exe` out on its own leaves it unable to run a +build action. + +## Nothing else to configure + +A repository that asks for the go, cc, shell or python plugin the ordinary way works as it is. +Where this build bundles them, they resolve from the install directory rather than being +downloaded, so a machine with no internet access builds the same as one with it. To use the +published plugins instead, delete the `plugin_*.zip` files. + +**Language toolchains are not bundled, and never are on any platform.** Building Go needs Go, +C++ needs a compiler, and Python needs an interpreter, each installed separately and pointed at +from your repository's `.plzconfig` the same way it would be anywhere else. What is bundled is +only what Please itself needs. + +## Known limitations + +- **`plz update` does not refresh everything.** It fetches only the Please binary, so busybox, + the archive tool and any bundled plugins stay at the version you first installed. Re-extract + the zip instead of updating in place. +- **A repository that pins an exact `[please] version`** will try to download that version from + the Please download server, which has no Windows release yet. Use a `>=` constraint, or set + `selfupdate = false`. +- **Build sandboxing is off.** It is built on Linux namespaces and there is no Windows + equivalent yet, so build actions and tests see the whole machine. +- **Real-time antivirus scanning locks files Please has just written**, which shows up as + intermittent sharing-violation errors and slow builds. Excluding your `plz-out` directories + helps. +- **Long paths.** Output paths nest deeply. If your repository lives far from the root of a + drive, turn on Windows long-path support. + +## Where a bundled plugin came from + +If this build carries plugins, `plugin_revisions.txt` names the exact commit of each, and they +are **not** the upstream releases their version numbers suggest: each is that release plus +Windows fixes that are not published anywhere yet. + +They also answer for whatever revision your repository asks for. A repository pinning a +different version of a plugin silently gets the bundled one on Windows. That is what lets an +unmodified repository build with no network at all; deleting the archive opts back out of it. diff --git a/package/plz.cmd b/package/plz.cmd new file mode 100644 index 0000000000..ebb44257c6 --- /dev/null +++ b/package/plz.cmd @@ -0,0 +1,4 @@ +@echo off +rem Please is installed as please.exe; this is the short name people actually type. +rem %~dp0 is the directory this script lives in, with a trailing backslash. +"%~dp0please.exe" %* diff --git a/pleasew.ps1 b/pleasew.ps1 new file mode 100644 index 0000000000..e9d94f03a4 --- /dev/null +++ b/pleasew.ps1 @@ -0,0 +1,140 @@ +# The Windows counterpart of pleasew: find or download the Please version this repo asks for, +# then hand over to it. Kept deliberately parallel to that script rather than clever, so the +# two can be read side by side. + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# This fork publishes GitHub Releases rather than to the bucket upstream uses; see +# tools/misc/get_plz.ps1. Set [please] downloadlocation to the bucket to use that instead - the +# path shape differs, and the base says which one to build. +$DefaultUrlBase = 'https://github.com/PeterNeiss/please/releases/download' + +if ($env:PROCESSOR_ARCHITECTURE -eq 'AMD64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'AMD64') { + $Arch = 'amd64' +} else { + Write-Error "Please does not support the $env:PROCESSOR_ARCHITECTURE architecture on Windows." + exit 1 +} +$Os = 'windows' + +# Check PLZ_CONFIG_PROFILE, or fall back to a --profile argument. +function Get-Profile { + if ($env:PLZ_CONFIG_PROFILE) { return $env:PLZ_CONFIG_PROFILE } + for ($i = 0; $i -lt $args.Count; $i++) { + if ($args[$i] -like '--profile=*') { return $args[$i].Split('=', 2)[1] } + if ($args[$i] -eq '--profile' -and $i + 1 -lt $args.Count) { return $args[$i + 1] } + } + return '' +} + +# Find the repo root by walking up until we see a .plzconfig. +function Find-RepoRoot { + $dir = Get-Location + while ($dir) { + if (Test-Path (Join-Path $dir '.plzconfig')) { return $dir.ToString() } + $parent = Split-Path -Parent $dir + if ($parent -eq $dir -or -not $parent) { return '' } + $dir = $parent + } + return '' +} + +$Profile_ = Get-Profile @args +$RepoRoot = Find-RepoRoot + +# Config files in order of precedence, high to low. +$Configs = @() +if ($RepoRoot) { + $Configs += Join-Path $RepoRoot '.plzconfig.local' + if ($Profile_) { $Configs += Join-Path $RepoRoot ".plzconfig.$Profile_" } + $Configs += Join-Path $RepoRoot ".plzconfig_${Os}_${Arch}" + $Configs += Join-Path $RepoRoot '.plzconfig' +} +$Configs += Join-Path $env:USERPROFILE '.config\please\plzconfig' +$Configs += Join-Path $env:ProgramData 'please\plzconfig' + +# Returns the value of the first key matching the pattern, across the config files in order. +function Read-Config([string] $Pattern) { + foreach ($config in $Configs) { + if (-not (Test-Path $config)) { continue } + $match = Select-String -Path $config -Pattern $Pattern -CaseSensitive:$false | Select-Object -First 1 + if ($match) { + $parts = $match.Line -split '=', 2 + if ($parts.Count -eq 2) { return $parts[1].Trim() } + } + } + return '' +} + +$Location = Read-Config '^\s*location' +if ($Location) { + # It can contain a literal ~, which nothing on Windows expands for us. + $Location = $Location -replace '^~', $env:USERPROFILE +} else { + $Location = Join-Path $env:USERPROFILE '.please' +} + +# If Please is already here at any version, let it handle any update itself. +$Target = Join-Path $Location 'please.exe' +if (Test-Path $Target) { + & $Target @args + exit $LASTEXITCODE +} + +$UrlBase = Read-Config '^\s*downloadlocation' +if (-not $UrlBase) { $UrlBase = $DefaultUrlBase } +$UrlBase = $UrlBase.TrimEnd('/') + +$Version = Read-Config '^\s*version[^a-z]' +$Version = $Version -replace '^>=', '' +if (-not $Version) { + Write-Warning "Can't determine version, will use latest." + if ($UrlBase -like '*github.com*') { + # A GitHub release has no latest_version file; the redirect on /releases/latest names + # the tag. + $Repo = ($UrlBase -replace '/releases/download$', '') + $Version = ((Invoke-WebRequest -UseBasicParsing "$Repo/releases/latest").BaseResponse.RequestMessage.RequestUri.AbsoluteUri -split '/')[-1] -replace '^v', '' + } else { + $Version = (Invoke-WebRequest -UseBasicParsing "$UrlBase/latest_version").Content.Trim() + } +} + +$Dir = Join-Path $Location $Version +$Zip = Join-Path ([System.IO.Path]::GetTempPath()) "please_$Version.zip" + +Write-Host "Downloading Please $Version to $Dir..." -ForegroundColor Green +if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir } +New-Item -ItemType Directory -Force -Path $Dir | Out-Null +# The two layouts differ: a release keeps everything under one tag with the platform in the +# filename, the bucket keeps a directory per platform and version. +$Url = if ($UrlBase -like '*github.com*') { + "$UrlBase/v$Version/please_${Version}_${Os}_${Arch}.zip" +} else { + "$UrlBase/${Os}_${Arch}/$Version/please_$Version.zip" +} +Invoke-WebRequest -UseBasicParsing $Url -OutFile $Zip + +# The zip holds everything under a please/ directory, which is the layer the tarball strips +# with --strip-components=1. Expand-Archive has no equivalent, so unpack and move up. +$Staging = Join-Path ([System.IO.Path]::GetTempPath()) "please_$Version" +if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging } +Expand-Archive -Path $Zip -DestinationPath $Staging +Move-Item (Join-Path $Staging 'please\*') $Dir +Remove-Item -Recurse -Force $Staging, $Zip + +# Link it all back up a directory. Symlinks need Developer Mode on Windows, so hard-link +# where we can and copy where we can't; this is the same choice the self-updater makes. +foreach ($file in Get-ChildItem -File $Dir) { + $link = Join-Path $Location $file.Name + if (Test-Path $link) { Remove-Item -Force $link } + try { + New-Item -ItemType HardLink -Path $link -Target $file.FullName -ErrorAction Stop | Out-Null + } catch { + Copy-Item -Force $file.FullName $link + } +} + +Write-Host 'Should be good to go now, running plz...' -ForegroundColor Green +& $Target @args +exit $LASTEXITCODE diff --git a/plugins/BUILD b/plugins/BUILD index 3d8c22f52d..51a142083a 100644 --- a/plugins/BUILD +++ b/plugins/BUILD @@ -1,23 +1,21 @@ -plugin_repo( - name = "go", - plugin = "go-rules", - revision = "v1.31.1", -) +# Each plugin is pinned here as an archive download. +# +# These point at forks rather than at please-build, because the Windows support in all four is +# on a branch that is not merged anywhere. Pinned to commit SHAs rather than to that branch: a +# branch archive changes whenever it is pushed to, which would silently move every build hash +# that reaches it and leave the cache serving something else. When the work lands upstream, the +# owner goes back to please-build and these become ordinary version tags. +PLUGINS = [ + ("go", "go-rules", "abd06f1"), + ("cc", "cc-rules", "90913bb"), + ("shell", "shell-rules", "7ed07be"), + ("python", "python-rules", "2edd835"), +] -plugin_repo( - name = "cc", - plugin = "cc-rules", - revision = "v0.7.3", -) - -plugin_repo( - name = "shell", - plugin = "shell-rules", - revision = "v0.2.1", -) - -plugin_repo( - name = "python", - plugin = "python-rules", - revision = "v2.0.2", -) +for name, plugin, revision in PLUGINS: + plugin_repo( + name = name, + owner = "PeterNeiss", + plugin = plugin, + revision = revision, + ) diff --git a/rules/misc_rules.build_defs b/rules/misc_rules.build_defs index 0aaf7e7510..734b299b0f 100644 --- a/rules/misc_rules.build_defs +++ b/rules/misc_rules.build_defs @@ -650,6 +650,11 @@ def tarball(name:str, srcs:list, out:str=None, deps:list=None, subdir:str=None, tar_out = out or (name + ('.tar.gz' if gzip else '.tar')) cmd = '$TOOL tar ' tar_name = name + if xzip and CONFIG.OS == 'windows': + # The xz applet in the busybox we bundle on Windows only decompresses, and Windows + # has no other xz to fall back on. Callers that need a compressed archive there + # should use gzip, which is built into the tar tool. + fail('tarball(xzip = True) is not supported on Windows; use gzip = True instead') if xzip: tar_out = name + '.tar' tar_name = f'_{name}#tar' diff --git a/src/assets/assets.go b/src/assets/assets.go index b5507d40b7..036a092b5d 100644 --- a/src/assets/assets.go +++ b/src/assets/assets.go @@ -10,6 +10,11 @@ import ( //go:embed pleasew var Pleasew []byte +// PleasewPS1 is the Windows counterpart of the wrapper script +// +//go:embed pleasew.ps1 +var PleasewPS1 []byte + // PlzComplete is the plz completion script // //go:embed plz_complete.sh diff --git a/src/assets/pleasew.ps1 b/src/assets/pleasew.ps1 new file mode 100644 index 0000000000..c247535c1c --- /dev/null +++ b/src/assets/pleasew.ps1 @@ -0,0 +1 @@ +needed for `go build src/please.go` \ No newline at end of file diff --git a/src/build/BUILD b/src/build/BUILD index 5a30845694..0598ec6f8f 100644 --- a/src/build/BUILD +++ b/src/build/BUILD @@ -31,7 +31,9 @@ go_test( "incrementality_test.go", "remote_file_test.go", ], - data = ["test_data"], + data = [":test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":build", "///third_party/go/github.com_stretchr_testify//assert", @@ -54,3 +56,11 @@ go_test( "//src/plz", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/build/build_step.go b/src/build/build_step.go index af13ea2b20..b49b96dcb1 100644 --- a/src/build/build_step.go +++ b/src/build/build_step.go @@ -671,7 +671,11 @@ func addOutputDirectoryToBuildOutput(target *core.BuildTarget, dir core.OutputDi func copyOutDir(target *core.BuildTarget, from string, to string) ([]string, error) { relativeToTmpdir := func(path string) string { - return strings.TrimPrefix(strings.TrimPrefix(path, target.TmpDir()), "/") + // ToSlash first: the argument was assembled with filepath.Join and so uses the host + // separator, while TmpDir is slash-separated. Without it neither prefix matches on + // Windows, the whole path survives as the output name, and moveOutputs then joins the + // temp directory onto a path that already contains it. + return strings.TrimPrefix(strings.TrimPrefix(filepath.ToSlash(path), target.TmpDir()), "/") } var outs []string @@ -700,6 +704,17 @@ func copyOutDir(target *core.BuildTarget, from string, to string) ([]string, err return outs, os.Rename(from, to) } +// fileURLPath returns the filesystem path a file:// URL refers to. +// The path component of such a URL always begins with a slash, so on Windows the drive letter +// arrives as /C:/foo and the slash has to come off before it is an absolute path at all. +func fileURLPath(url string) string { + path := strings.TrimPrefix(url, "file://") + if filepath.Separator == '\\' && len(path) >= 3 && path[0] == '/' && path[2] == ':' { + return path[1:] + } + return path +} + func moveOutputs(state *core.BuildState, target *core.BuildTarget) ([]string, bool, error) { changed := false tmpDir := target.TmpDir() @@ -1016,11 +1031,14 @@ func checkLicences(state *core.BuildState, target *core.BuildTarget) { // buildLinks builds links from the given target if it's labelled appropriately. // For example, Go targets may link themselves into plz-out/go/src etc. func buildLinks(state *core.BuildState, target *core.BuildTarget) { - buildLinksOfType(state, target, "link:", false, os.Symlink) + // SymlinkOrCopy rather than os.Symlink: Windows refuses to create one without a privilege + // an ordinary user does not have, and a link: label that silently becomes a warning is + // worse than a copy. + buildLinksOfType(state, target, "link:", false, fs.SymlinkOrCopy) buildLinksOfType(state, target, "hlink:", false, os.Link) // Directly link to the path of the label for these (i.e. don't append out to the destination dir) - buildLinksOfType(state, target, "dlink:", true, os.Symlink) + buildLinksOfType(state, target, "dlink:", true, fs.SymlinkOrCopy) buildLinksOfType(state, target, "dhlink:", true, os.Link) if state.Config.ShouldLinkGeneratedSources() && target.HasLabel("codegen") { @@ -1094,10 +1112,10 @@ func fetchOneRemoteFile(state *core.BuildState, target *core.BuildTarget, url st } defer f.Close() if strings.HasPrefix(url, "file://") { - filename := strings.TrimPrefix(url, "file://") + filename := fileURLPath(url) if !filepath.IsAbs(filename) { return fmt.Errorf("URL %s must be an absolute path", url) - } else if strings.HasPrefix(filename, core.RepoRoot) { + } else if core.IsInRepoRoot(filename) { return fmt.Errorf("URL %s is within the repo, you cannot use remote_file for this", url) } fromfile, err := os.Open(filename) diff --git a/src/build/build_step_test.go b/src/build/build_step_test.go index 0e4642ce83..d0a4dc378f 100644 --- a/src/build/build_step_test.go +++ b/src/build/build_step_test.go @@ -14,6 +14,7 @@ import ( iofs "io/fs" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -78,6 +79,7 @@ func TestModifiedBuildTargetStillNeedsRebuilding(t *testing.T) { } func TestSymlinkedOutputs(t *testing.T) { + skipIfNoSymlinks(t) // Test behaviour when the output is a symlink. state, target := newState("//package1:target5") target.AddOutput("file5") @@ -117,6 +119,17 @@ func TestPostBuildFunction(t *testing.T) { assert.Equal(t, []string{"file7"}, target.Outputs()) } +// assertPermissionsPreserved checks that the mode a build action set on a file survived being +// moved into plz-out. Windows has no mode to preserve - Go synthesises one from the read-only +// attribute - so there is nothing to assert there. +func assertPermissionsPreserved(t *testing.T, info os.FileInfo) { + t.Helper() + if runtime.GOOS == "windows" { + return + } + assert.Equal(t, "-rwxrwxrwx", info.Mode().Perm().String()) +} + func TestOutputDir(t *testing.T) { newTarget := func() (*core.BuildState, *core.BuildTarget) { // Test modifying a command in the post-build function. @@ -147,6 +160,19 @@ func TestOutputDir(t *testing.T) { assert.Equal(t, core.Reused, target.State()) } +// skipIfNoSymlinks skips a test that needs working symlinks. Creating one on Windows needs +// Developer Mode, and under Wine os.Symlink reports success while producing a link that cannot +// even be stat'ed - so a failure here says nothing about Please. See docs/design/windows. +func skipIfNoSymlinks(t *testing.T) { + t.Helper() + if fs.IsWine() { + // Only under Wine, where os.Symlink reports success and produces a link that cannot be + // stat'ed. Real Windows either makes the link or falls back to a copy, and both leave + // the content these tests assert on where it should be. + t.Skip("Wine's symlinks are not real enough to assert against") + } +} + func TestOutputDirDoubleStar(t *testing.T) { newTarget := func(withDoubleStar bool) (*core.BuildState, *core.BuildTarget) { // Test modifying a command in the post-build function. @@ -176,7 +202,7 @@ func TestOutputDirDoubleStar(t *testing.T) { info, err := os.Lstat(filepath.Join(target.OutDir(), "foo/file7")) require.NoError(t, err) - assert.Equal(t, info.Mode().Perm().String(), "-rwxrwxrwx") + assertPermissionsPreserved(t, info) state, target = newTarget(true) @@ -186,7 +212,7 @@ func TestOutputDirDoubleStar(t *testing.T) { info, err = os.Lstat(filepath.Join(target.OutDir(), "foo/file7")) require.NoError(t, err) - assert.Equal(t, info.Mode().Perm().String(), "-rwxrwxrwx") + assertPermissionsPreserved(t, info) } func TestCacheRetrieval(t *testing.T) { @@ -272,6 +298,7 @@ func TestGoModCreation(t *testing.T) { } func TestCreatePlzOutGo(t *testing.T) { + skipIfNoSymlinks(t) state, target := newState("//package1:target") target.AddLabel("link:plz-out/go/${PKG}/src") target.AddOutput("file1.go") @@ -399,7 +426,12 @@ func TestHashCheckers(t *testing.T) { func TestFetchLocalRemoteFile(t *testing.T) { state, target := newState("//package4:target1") - target.AddSource(core.URLLabel("file://" + os.Getenv("TMP_DIR") + "/src/build/test_data/local_remote_file.txt")) + // From the working directory, which is the test data directory, rather than $TMP_DIR: + // that names the same place but as the host we started from writes it, which under Wine + // is not an absolute path at all. + wd, err := os.Getwd() + require.NoError(t, err) + target.AddSource(core.URLLabel("file://" + filepath.ToSlash(wd) + "/local_remote_file.txt")) target.AddOutput("local_remote_file.txt") // Temporarily reset the repo root so we can test this locally @@ -409,11 +441,21 @@ func TestFetchLocalRemoteFile(t *testing.T) { core.RepoRoot = oldRoot }() - err := fetchRemoteFile(state, target) - assert.NoError(t, err) + assert.NoError(t, fetchRemoteFile(state, target)) assert.True(t, fs.FileExists(filepath.Join(target.TmpDir(), "local_remote_file.txt"))) } +func TestFileURLPath(t *testing.T) { + // A Unix path round-trips unchanged; a Windows one loses the slash the URL form requires + // before its drive letter, but only where that is what a drive letter means. + assert.Equal(t, "/home/user/file.txt", fileURLPath("file:///home/user/file.txt")) + if filepath.Separator == '\\' { + assert.Equal(t, `C:/foo/bar`, fileURLPath("file:///C:/foo/bar")) + } else { + assert.Equal(t, `/C:/foo/bar`, fileURLPath("file:///C:/foo/bar")) + } +} + func TestFetchLocalRemoteFileCannotBeRelative(t *testing.T) { state, target := newState("//package4:target2") target.AddSource(core.URLLabel("src/build/test_data/local_remote_file.txt")) diff --git a/src/build/remote_file_test.go b/src/build/remote_file_test.go index 6fe86a6705..e1bb003e0d 100644 --- a/src/build/remote_file_test.go +++ b/src/build/remote_file_test.go @@ -25,6 +25,17 @@ func listen(s *http.Server) net.Listener { return lis } +// writeHomeSecret puts the secret the tests read at ~/secret, with the home directory pointed +// somewhere this test owns. Writing to the real one would leave a file behind, and it is left +// read-only, which on Windows means the next run cannot replace it. +func writeHomeSecret(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // What os.UserHomeDir reads on Windows. + require.NoError(t, fs.CopyFile("secret", fs.ExpandHomePath("~/secret"), 0444)) +} + func TestHeader(t *testing.T) { state, target := newState("//pkg:header_test") target.IsRemoteFile = true @@ -53,8 +64,7 @@ func TestSecretHeader(t *testing.T) { target.AddLabel("remote_file:secret_header:foo:~/secret") target.AddLabel("remote_file:secret_header:bar:secret") - err := fs.CopyFile("secret", fs.ExpandHomePath("~/secret"), 0444) - require.NoError(t, err) + writeHomeSecret(t) s, m := Server() m.HandleFunc("/header", func(writer http.ResponseWriter, request *http.Request) { @@ -68,8 +78,7 @@ func TestSecretHeader(t *testing.T) { lis := listen(s) go s.Serve(lis) - err = fetchRemoteFile(state, target) - require.NoError(t, err) + require.NoError(t, fetchRemoteFile(state, target)) } func TestBasicAuth(t *testing.T) { @@ -80,8 +89,7 @@ func TestBasicAuth(t *testing.T) { target.AddLabel("remote_file:username:foo") target.AddLabel("remote_file:password_file:~/secret") - err := fs.CopyFile("secret", fs.ExpandHomePath("~/secret"), 0444) - require.NoError(t, err) + writeHomeSecret(t) s, m := Server() m.HandleFunc("/header", func(writer http.ResponseWriter, request *http.Request) { @@ -94,6 +102,5 @@ func TestBasicAuth(t *testing.T) { lis := listen(s) go s.Serve(lis) - err = fetchRemoteFile(state, target) - require.NoError(t, err) + require.NoError(t, fetchRemoteFile(state, target)) } diff --git a/src/cache/BUILD b/src/cache/BUILD index 72d59791b0..6a4c484828 100644 --- a/src/cache/BUILD +++ b/src/cache/BUILD @@ -15,16 +15,27 @@ go_library( "//src/cli/logging", "//src/core", "//src/fs", + "//src/process", ], ) go_test( name = "cache_test", srcs = glob(["*_test.go"]), - data = ["test_data"], + data = [":cache_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":cache", "///third_party/go/github.com_stretchr_testify//assert", "//src/core", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "cache_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/cache/cmd_cache.go b/src/cache/cmd_cache.go index 650c3fe786..927a2a5567 100644 --- a/src/cache/cmd_cache.go +++ b/src/cache/cmd_cache.go @@ -8,14 +8,18 @@ import ( "io" "os/exec" "path/filepath" + "slices" "github.com/thought-machine/please/src/core" "github.com/thought-machine/please/src/fs" + "github.com/thought-machine/please/src/process" ) type cmdCache struct { storeCommand string retrieveCommand string + // The shell the two commands run in, as argv up to but not including the command. + shell []string } func keyToString(key []byte) string { @@ -30,7 +34,8 @@ func (cache *cmdCache) Store(target *core.BuildTarget, key []byte, files []strin ctx, cancel := context.WithCancel(context.Background()) defer cancel() - cmd := exec.CommandContext(ctx, "sh", "-c", cache.storeCommand) + argv := append(cache.shell, cache.storeCommand) + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Env = append(cmd.Env, "CACHE_KEY="+strKey) r, w := io.Pipe() @@ -52,7 +57,8 @@ func (cache *cmdCache) Retrieve(target *core.BuildTarget, key []byte, _ []string strKey := keyToString(key) log.Debug("Retrieve %s: %s from custom cache...", target.Label, strKey) - cmd := exec.Command("sh", "-c", cache.retrieveCommand) + argv := append(cache.shell, cache.retrieveCommand) + cmd := exec.Command(argv[0], argv[1:]...) cmd.Env = append(cmd.Env, "CACHE_KEY="+strKey) var cmdOutputBuffer bytes.Buffer @@ -130,8 +136,13 @@ func write(w io.WriteCloser, target *core.BuildTarget, files []string, cancel co } func newCmdCache(config *core.Configuration) *cmdCache { + // These are shell strings like any build action, so they run in the configured shell - + // on Windows there is no 'sh' to fall back on. Clipped so that appending the command to + // it can't write into this slice from two goroutines at once. + shell := process.ShellArgv(config.Shell(), config.ShellArgs()) return &cmdCache{ storeCommand: config.Cache.StoreCommand, retrieveCommand: config.Cache.RetrieveCommand, + shell: slices.Clip(append(shell, "-c")), } } diff --git a/src/clean/BUILD b/src/clean/BUILD index 1780bb6549..89178876fe 100644 --- a/src/clean/BUILD +++ b/src/clean/BUILD @@ -1,6 +1,10 @@ go_library( name = "clean", - srcs = ["clean.go"], + srcs = [ + "clean.go", + "detach_other.go", + "detach_windows.go", + ], pgo_file = "//:pgo", visibility = ["PUBLIC"], deps = [ @@ -9,12 +13,14 @@ go_library( "//src/core", "//src/fs", "//src/test", - ], + ] + (["///third_party/go/golang.org_x_sys//windows"] if is_platform(os = "windows") else []), ) go_test( name = "clean_test", srcs = ["clean_test.go"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":clean", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/clean/clean.go b/src/clean/clean.go index 79b09d7f6d..de2b35b36e 100644 --- a/src/clean/clean.go +++ b/src/clean/clean.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "syscall" "github.com/thought-machine/please/src/build" "github.com/thought-machine/please/src/cli/logging" @@ -91,10 +90,9 @@ func AsyncDeleteDir(dir string) error { if err != nil { return err } - // Note that we can't fork() directly and continue running Go code, but ForkExec() works okay, - // so we re-execute ourselves with a specific command that will remove this. - _, err = syscall.ForkExec(exec, []string{exec, "clean", "--rm", newDir}, nil) - return err + // Note that we can't fork() directly and continue running Go code, so we re-execute + // ourselves detached, with a specific command that will remove this. + return startDetached(exec, []string{"clean", "--rm", newDir}) } // moveDir moves a directory to a new location and returns that new location. diff --git a/src/clean/detach_other.go b/src/clean/detach_other.go new file mode 100644 index 0000000000..849460cac3 --- /dev/null +++ b/src/clean/detach_other.go @@ -0,0 +1,11 @@ +//go:build !windows +// +build !windows + +package clean + +import "os/exec" + +// startDetached starts a process that will outlive us, and does not wait for it. +func startDetached(bin string, args []string) error { + return exec.Command(bin, args...).Start() +} diff --git a/src/clean/detach_windows.go b/src/clean/detach_windows.go new file mode 100644 index 0000000000..1784c85de1 --- /dev/null +++ b/src/clean/detach_windows.go @@ -0,0 +1,17 @@ +package clean + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// startDetached starts a process that will outlive us, and does not wait for it. +// DETACHED_PROCESS keeps it off our console, so it isn't killed when we exit or when the +// user closes the window. +func startDetached(bin string, args []string) error { + cmd := exec.Command(bin, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS} + return cmd.Start() +} diff --git a/src/cli/BUILD b/src/cli/BUILD index 666d64eb77..16ef8abe0d 100644 --- a/src/cli/BUILD +++ b/src/cli/BUILD @@ -7,6 +7,8 @@ go_library( "progress.go", "prompt.go", "replacements.go", + "signals_other.go", + "signals_windows.go", "suggest.go", "winch_other.go", "winch_windows.go", @@ -35,6 +37,8 @@ go_test( "flags_test.go", "logging_test.go", ], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":cli", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/cli/logging.go b/src/cli/logging.go index 41547e0b22..7c4ea43262 100644 --- a/src/cli/logging.go +++ b/src/cli/logging.go @@ -7,7 +7,7 @@ import ( "container/list" "fmt" "os" - "path" + "path/filepath" "regexp" "strings" "sync" @@ -61,7 +61,7 @@ func InitLogging(verbosity Verbosity) { // InitFileLogging initialises an optional logging backend to a file. func InitFileLogging(logFile string, logFileLevel Verbosity, append bool) { fileLogLevel = logging.Level(logFileLevel) - if err := os.MkdirAll(path.Dir(logFile), os.ModeDir|0775); err != nil { + if err := os.MkdirAll(filepath.Dir(logFile), os.ModeDir|0775); err != nil { log.Fatalf("Error creating log file directory: %s", err) } flags := os.O_RDWR | os.O_CREATE | os.O_TRUNC @@ -75,11 +75,28 @@ func InitFileLogging(logFile string, logFileLevel Verbosity, append bool) { fileBackend = logging.NewLogBackend(file, "", 0) fileBackend = logging.NewBackendFormatter(fileBackend, logFormatter(false)) setLogBackend(logging.NewLogBackend(os.Stderr, "", 0)) - AtExit(func() { - fileBackend = nil - setLogBackend(logging.NewLogBackend(os.Stderr, "", 0)) - file.Close() - }) + openLogFile = file + AtExit(CloseFileLogging) +} + +// openLogFile is the open log file, if there is one, so that CloseFileLogging can reach it. +var openLogFile *os.File + +// CloseFileLogging stops logging to a file and closes it, leaving stderr logging in place. +// +// Anything that deletes or renames a directory the log file is in has to call this first. +// Windows refuses to rename or unlink a file another handle has open, and the default log file +// lives at plz-out/log/build.log - which is inside the directory plz clean removes, so a clean +// could not do either. AtExit is not enough on its own: it only runs on a terminating signal, +// never on an ordinary exit. +func CloseFileLogging() { + if openLogFile == nil { + return + } + fileBackend = nil + setLogBackend(logging.NewLogBackend(os.Stderr, "", 0)) + openLogFile.Close() + openLogFile = nil } func logFormatter(coloured bool) logging.Formatter { diff --git a/src/cli/process.go b/src/cli/process.go index 44a0c4cd23..d9ac784d3d 100644 --- a/src/cli/process.go +++ b/src/cli/process.go @@ -3,7 +3,6 @@ package cli import ( "os" "os/signal" - "syscall" ) var atexitHandlers []func() @@ -16,7 +15,7 @@ func init() { // functions previously registered with AtExit, and then exits the process. func handleSignals() { ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGABRT, syscall.SIGTERM) + signal.Notify(ch, terminatingSignals...) sig := <-ch log.Info("Received signal %s", sig) // Allow a second signal to terminate the process regardless @@ -47,8 +46,5 @@ func AtExit(f func()) { // exit kills the process with an exit code suitable for the given signal. func exit(sig os.Signal) { - if s, ok := sig.(syscall.Signal); ok { - os.Exit(128 + int(s)) - } - os.Exit(1) + os.Exit(exitCodeForSignal(sig)) } diff --git a/src/cli/signals_other.go b/src/cli/signals_other.go new file mode 100644 index 0000000000..2ca76a4acc --- /dev/null +++ b/src/cli/signals_other.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package cli + +import ( + "os" + "syscall" +) + +// terminatingSignals are the signals we clean up and exit on. +var terminatingSignals = []os.Signal{ + syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGABRT, syscall.SIGTERM, +} + +// exitCodeForSignal returns the conventional shell exit code for dying to a signal. +func exitCodeForSignal(sig os.Signal) int { + if s, ok := sig.(syscall.Signal); ok { + return 128 + int(s) + } + return 1 +} diff --git a/src/cli/signals_windows.go b/src/cli/signals_windows.go new file mode 100644 index 0000000000..3cebccf77b --- /dev/null +++ b/src/cli/signals_windows.go @@ -0,0 +1,16 @@ +package cli + +import ( + "os" + "syscall" +) + +// terminatingSignals are the signals we clean up and exit on. Windows only ever delivers +// Ctrl-C as os.Interrupt and a synthesised SIGTERM; the others are defined but never sent. +var terminatingSignals = []os.Signal{os.Interrupt, syscall.SIGTERM} + +// exitCodeForSignal returns the exit code to use when dying to a signal. The 128+signum +// convention is a shell idiom with no meaning on Windows, so just report a plain failure. +func exitCodeForSignal(sig os.Signal) int { + return 1 +} diff --git a/src/cmap/cmap.go b/src/cmap/cmap.go index f8058ef732..8e197ebf68 100644 --- a/src/cmap/cmap.go +++ b/src/cmap/cmap.go @@ -84,6 +84,13 @@ func (m *Map[K, V]) GetOrWait(key K) (val V, wait <-chan struct{}, first bool) { return m.shards[m.hasher(key)&m.mask].Get(key) } +// Delete removes the given key from the map, returning the value it had and whether there was +// one. A key that only exists because something is waiting on it through GetOrWait is left +// alone: deleting a key is not the same as it arriving, and the waiters would never be woken. +func (m *Map[K, V]) Delete(key K) (V, bool) { + return m.shards[m.hasher(key)&m.mask].Delete(key) +} + // Values returns a slice of all the current values in the map. // No particular consistency guarantees are made. func (m *Map[K, V]) Values() []V { @@ -183,6 +190,19 @@ func (s *shard[K, V]) Get(key K) (val V, wait <-chan struct{}, first bool) { return } +// Delete removes a key that has a value, returning it. It reports false, and does nothing, for +// a key that is absent or is only a placeholder something is waiting on. +func (s *shard[K, V]) Delete(key K) (V, bool) { + s.l.Lock() + defer s.l.Unlock() + if v, present := s.m[key]; present && v.Wait == nil { + delete(s.m, key) + return v.Val, true + } + var zero V + return zero, false +} + // Values returns a copy of all the targets currently in the map. func (s *shard[K, V]) Values() []V { s.l.RLock() diff --git a/src/cmap/cmap_test.go b/src/cmap/cmap_test.go index 2c4394f8a8..03a5f3ba06 100644 --- a/src/cmap/cmap_test.go +++ b/src/cmap/cmap_test.go @@ -56,6 +56,33 @@ func TestReAdd(t *testing.T) { assert.False(t, first) } +func TestDelete(t *testing.T) { + m := New[int, int](DefaultShardCount, hashInts) + assert.True(t, m.Add(5, 7)) + v, deleted := m.Delete(5) + assert.True(t, deleted) + assert.Equal(t, 7, v) + assert.False(t, m.Contains(5)) + // Deleting it again does nothing, and the key is free to be added afresh. + _, deleted = m.Delete(5) + assert.False(t, deleted) + assert.True(t, m.Add(5, 9)) + assert.Equal(t, 9, m.Get(5)) +} + +func TestDeleteLeavesWaitersAlone(t *testing.T) { + // A key that only exists because something is waiting on it has no value to delete, and + // removing it would leave the waiter waiting on a channel nothing can close. + m := New[int, int](DefaultShardCount, hashInts) + _, ch, first := m.GetOrWait(5) + assert.True(t, first) + _, deleted := m.Delete(5) + assert.False(t, deleted) + m.Set(5, 7) + <-ch + assert.Equal(t, 7, m.Get(5)) +} + func TestAddOrGet(t *testing.T) { m := New[int, int](DefaultShardCount, hashInts) x, inserted := m.AddOrGet(5, func() int { return 7 }) diff --git a/src/core/BUILD b/src/core/BUILD index d4b351dd22..e4294844b9 100644 --- a/src/core/BUILD +++ b/src/core/BUILD @@ -29,14 +29,15 @@ go_library( "//src/process", "//src/scm", "//src/version", - ], + ] + (["///third_party/go/golang.org_x_sys//windows"] if is_platform(os = "windows") else []), ) go_test( name = "core_test", srcs = glob(["*_test.go"]), - data = ["test_data"], - filter_srcs = False, # As above + data = [":test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":core", "///third_party/go/github.com_stretchr_testify//assert", @@ -64,3 +65,11 @@ benchmark( srcs = ["graph_benchmark_test.go"], deps = [":core"], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/core/build_env.go b/src/core/build_env.go index b3c0bcfbb8..cc06476eaf 100644 --- a/src/core/build_env.go +++ b/src/core/build_env.go @@ -80,6 +80,7 @@ func BuildEnvironment(state *BuildState, target *BuildTarget, tmpDir string) Bui env["TMPDIR"] = tmpDir env["OUTS"] = strings.Join(outEnv, " ") env["HOME"] = tmpDir + setPlatformTmpEnv(env, tmpDir) // Set a consistent hash seed for Python. Important for build determinism. env["PYTHONHASHSEED"] = "42" @@ -132,6 +133,7 @@ func BuildEnvironment(state *BuildState, target *BuildTarget, tmpDir string) Bui env["BINDIR"] = filepath.Join(RepoRoot, BinDir) } + env.normalisePathSeparators() return withUserProvidedEnv(target, env) } @@ -164,6 +166,7 @@ func TestEnvironment(state *BuildState, target *BuildTarget, testDir string, run env["TMP_DIR"] = testDir env["TMPDIR"] = testDir env["HOME"] = testDir + setPlatformTmpEnv(env, testDir) env["TEST_ARGS"] = strings.Join(state.TestArgs, ",") env["RESULTS_FILE"] = resultsFile // We shouldn't really have specific things like this here, but it really is just easier to set it. @@ -190,6 +193,7 @@ func TestEnvironment(state *BuildState, target *BuildTarget, testDir string, run if len(state.TestArgs) > 0 { env["TESTS"] = strings.Join(state.TestArgs, " ") } + env.normalisePathSeparators() return withUserProvidedEnv(target, env) } @@ -204,6 +208,7 @@ func RunEnvironment(state *BuildState, target *BuildTarget, inTmpDir bool) Build env["OUT"] = resolveOut(outEnv[0], ".", false) } + env.normalisePathSeparators() return withUserProvidedEnv(target, env) } @@ -213,6 +218,7 @@ func ExecEnvironment(state *BuildState, target *BuildTarget, execDir string) Bui env["TMP_DIR"] = execDir env["TMPDIR"] = execDir env["HOME"] = execDir + setPlatformTmpEnv(env, execDir) // This is used by programs that use display terminals for correct handling // of input and output in the terminal where the program is run. env["TERM"] = os.Getenv("TERM") @@ -228,6 +234,7 @@ func ExecEnvironment(state *BuildState, target *BuildTarget, execDir string) Bui } } + env.normalisePathSeparators() return withUserProvidedEnv(target, env) } @@ -350,7 +357,10 @@ func toolPath(state *BuildState, tool BuildInput, abs bool) string { entryPoint = o.Annotation } path := state.Graph.TargetOrDie(label).toolPath(abs, entryPoint) - if !strings.Contains(path, "/") { + // A bare filename is made explicit so the shell runs it rather than searching PATH. + // Check both separators: on Windows the path may still contain backslashes at this + // point, and treating one as a bare name yields nonsense like "./C:\dir\tool.exe". + if !strings.ContainsRune(path, '/') && !strings.ContainsRune(path, os.PathSeparator) { path = "./" + path } return path diff --git a/src/core/build_env_test.go b/src/core/build_env_test.go index ca2b22210f..8302a81e41 100644 --- a/src/core/build_env_test.go +++ b/src/core/build_env_test.go @@ -2,6 +2,7 @@ package core import ( "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -203,3 +204,23 @@ func TestDeduplicateEnvVars(t *testing.T) { env := TestEnvironment(state, target, "/path/to/runtime/dir", 1) assert.Equal(t, env["COVERAGE"], "wibble") } + +// TestBuildEnvironmentUsesForwardSlashes asserts the invariant that the environment Please +// generates never contains a backslash. Build commands are shell strings, and anything that +// interprets its arguments - sed, for one, which the C/C++ rules use to build their link line +// - will silently mangle a Windows path embedded in one. This is trivially true on platforms +// whose separator is already a forward slash; it is the real assertion on Windows. +func TestBuildEnvironmentUsesForwardSlashes(t *testing.T) { + target := NewBuildTarget(NewBuildLabel("pkg", "t")) + target.AddOutput("out_file1") + target.AddSource(FileLabel{File: "src_file1", Package: "pkg"}) + + for name, env := range map[string]BuildEnv{ + "build": BuildEnvironment(NewDefaultBuildState(), target, filepath.Join("path", "to", "tmp")), + "exec": ExecEnvironment(NewDefaultBuildState(), target, filepath.Join("path", "to", "run")), + } { + for k, v := range env { + assert.NotContains(t, v, `\`, "%s environment: %s contains a backslash", name, k) + } + } +} diff --git a/src/core/build_label.go b/src/core/build_label.go index 365e851c8c..a02d095470 100644 --- a/src/core/build_label.go +++ b/src/core/build_label.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "os" - "path/filepath" + "path" "strings" "time" @@ -74,7 +74,7 @@ func (label BuildLabel) ShortString(context BuildLabel) string { return label.String() } else if label.PackageName == context.PackageName { return ":" + label.Name - } else if label.Name == filepath.Base(label.PackageName) { + } else if label.Name == path.Base(label.PackageName) { return "//" + label.PackageName } label.Subrepo = "" @@ -258,7 +258,8 @@ func parseMaybeRelativeBuildLabel(target, subdir string) (BuildLabel, error) { return TryParseBuildLabel(target, subdir, "") } // Presumably it's just underneath this directory (note that if it was absolute we returned above) - return TryParseBuildLabel("//"+filepath.Join(subdir, target), "", "") + // path, not filepath: this is a build label, which is slash-separated on every platform. + return TryParseBuildLabel("//"+path.Join(subdir, target), "", "") } // ParseBuildLabels parses a bunch of build labels from strings. It dies on failure. @@ -352,10 +353,12 @@ func (label BuildLabel) FullPaths(graph *BuildGraph) []string { } // addPathPrefix adds a prefix to all the entries in a slice. +// path, not filepath: these are plz-out paths, which stay slash-separated - they are what +// $(location) and friends expand to inside a shell command. func addPathPrefix(paths []string, prefix string) []string { ret := make([]string, len(paths)) for i, output := range paths { - ret[i] = filepath.Join(prefix, output) + ret[i] = path.Join(prefix, output) } return ret } diff --git a/src/core/build_target.go b/src/core/build_target.go index 90c64943e2..1831ca0a95 100644 --- a/src/core/build_target.go +++ b/src/core/build_target.go @@ -40,7 +40,8 @@ const SubrepoDir = "plz-out/subrepos" const DefaultBuildingDescription = "Building..." // SandboxDir is the directory that sandboxed actions are run in. -const SandboxDir = "/tmp/plz_sandbox" +// This is platform-specific; see sandboxdir_other.go and sandboxdir_windows.go. +var SandboxDir = sandboxDir // Suffixes for temporary directories const buildDirSuffix = "._build" @@ -418,13 +419,19 @@ func (target *BuildTarget) String() string { return target.Label.String() } +// The directories below are built with path, not filepath, and so are slash-separated on every +// platform. They are assembled from build label components, which are slash-separated by +// definition, and they end up interpolated into shell commands, where a backslash is an escape +// character rather than a separator. Win32 accepts either, so nothing is lost by being +// consistent. See docs/design/windows/02-shell-and-build-actions.md. +// // TmpDir returns the temporary working directory for this target, eg. // //mickey/donald:goofy -> plz-out/tmp/mickey/donald/goofy._build // Note the extra subdirectory to keep rules separate from one another, and the .build suffix // to attempt to keep rules from duplicating the names of sub-packages; obviously that is not // 100% reliable but we don't have a better solution right now. func (target *BuildTarget) TmpDir() string { - return filepath.Join(TmpDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name+buildDirSuffix) + return path.Join(TmpDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name+buildDirSuffix) } // BuildLockFile returns the lock filename for the target's build stage. @@ -436,17 +443,17 @@ func (target *BuildTarget) BuildLockFile() string { // //mickey/donald:goofy -> plz-out/gen/mickey/donald (or plz-out/bin if it's a binary) func (target *BuildTarget) OutDir() string { if target.IsSubrepo { - return filepath.Join(SubrepoDir, target.Label.Subrepo, target.Label.PackageName) + return path.Join(SubrepoDir, target.Label.Subrepo, target.Label.PackageName) } else if target.IsBinary { - return filepath.Join(BinDir, target.Label.Subrepo, target.Label.PackageName) + return path.Join(BinDir, target.Label.Subrepo, target.Label.PackageName) } - return filepath.Join(GenDir, target.Label.Subrepo, target.Label.PackageName) + return path.Join(GenDir, target.Label.Subrepo, target.Label.PackageName) } // ExecDir returns the exec directory for this target, e.g. // //mickey/donald:goofy -> plz-out/exec/mickey/donald/goofy func (target *BuildTarget) ExecDir() string { - return filepath.Join(ExecDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name) + return path.Join(ExecDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name) } // TestDir returns the test directory for this target, eg. @@ -454,7 +461,7 @@ func (target *BuildTarget) ExecDir() string { // This is different to TmpDir so we run tests in a clean environment // and to facilitate containerising tests. func (target *BuildTarget) TestDir(runNumber int) string { - return filepath.Join(target.TestDirs(), fmt.Sprint("run_", runNumber)) + return path.Join(target.TestDirs(), fmt.Sprint("run_", runNumber)) } // TestLockFile returns the lock filename for the target's test stage. @@ -464,7 +471,7 @@ func (target *BuildTarget) TestLockFile(runNumber int) string { // TestDirs contains the parent directory of all the test run directories above func (target *BuildTarget) TestDirs() string { - return filepath.Join(TmpDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name+testDirSuffix) + return path.Join(TmpDir, target.Label.Subrepo, target.Label.PackageName, target.Label.Name+testDirSuffix) } // IsTest returns whether or not the target is a test target i.e. has its Test field populated @@ -483,12 +490,12 @@ func (target *BuildTarget) CompleteRun(state *BuildState) bool { // TestResultsFile returns the output results file for tests for this target. func (target *BuildTarget) TestResultsFile() string { - return filepath.Join(target.OutDir(), ".test_results_"+target.Label.Name) + return path.Join(target.OutDir(), ".test_results_"+target.Label.Name) } // CoverageFile returns the output coverage file for tests for this target. func (target *BuildTarget) CoverageFile() string { - return filepath.Join(target.OutDir(), ".test_coverage_"+target.Label.Name) + return path.Join(target.OutDir(), ".test_coverage_"+target.Label.Name) } // AddTestResults adds results to the target @@ -934,7 +941,7 @@ func (target *BuildTarget) FullOutputs() []string { outs := target.Outputs() outDir := target.OutDir() for i, out := range outs { - outs[i] = filepath.Join(outDir, out) + outs[i] = path.Join(outDir, out) } return outs } @@ -1079,14 +1086,14 @@ func (target *BuildTarget) CheckTargetOwnsBuildOutputs(state *BuildState) error for _, output := range target.Outputs() { targetPackage := target.Label.PackageName - out := filepath.Join(targetPackage, output) + out := path.Join(targetPackage, output) if fs.IsPackage(state.Config.Parse.BuildFileName, out) { return fmt.Errorf("trying to output file %s, but that directory is another package", out) } // If the output is just a file in the package root, we don't need to check anything else. - if filepath.Dir(output) == "." { + if path.Dir(output) == "." { continue } @@ -1861,9 +1868,11 @@ func (target *BuildTarget) toolPath(abs bool, namedOutput string) string { ret := make([]string, len(outputs)) for i, o := range outputs { if abs { - ret[i] = filepath.Join(RepoRoot, target.OutDir(), o) + // ToSlash because RepoRoot is a native path: this whole string is about to be + // interpolated into a shell command. + ret[i] = filepath.ToSlash(filepath.Join(RepoRoot, target.OutDir(), o)) } else { - ret[i] = filepath.Join(target.PackageDir(), o) + ret[i] = path.Join(target.PackageDir(), o) } } return strings.Join(ret, " ") @@ -2073,7 +2082,7 @@ func (target *BuildTarget) HasLinks(state *BuildState) bool { func (target *BuildTarget) PackageDir() string { if target.Subrepo != nil { - return filepath.Join(target.Subrepo.PackageRoot, target.Label.PackageDir()) + return path.Join(target.Subrepo.PackageRoot, target.Label.PackageDir()) } return target.Label.PackageDir() } diff --git a/src/core/build_target_test.go b/src/core/build_target_test.go index 4360be1316..528ba776ee 100644 --- a/src/core/build_target_test.go +++ b/src/core/build_target_test.go @@ -4,10 +4,12 @@ package core import ( "fmt" "os" + "path/filepath" "slices" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTmpDir(t *testing.T) { @@ -395,7 +397,9 @@ func TestToolPath(t *testing.T) { target.AddOutput("file2.go") wd, _ := os.Getwd() RepoRoot = wd - root := wd + "/plz-out/gen/src/core" + // Tool paths are interpolated into shell commands, so they are slash-separated even where + // the working directory we started from isn't. + root := filepath.ToSlash(wd) + "/plz-out/gen/src/core" assert.Equal(t, fmt.Sprintf("%s/file1.go %s/file2.go", root, root), target.toolPath(true, "")) assert.Equal(t, "src/core/file1.go src/core/file2.go", target.toolPath(false, "")) } @@ -407,7 +411,7 @@ func TestToolPathWithEntryPoint(t *testing.T) { target.EntryPoints = map[string]string{"f1": "file1.go"} wd, _ := os.Getwd() RepoRoot = wd - root := wd + "/plz-out/gen/src/core" + root := filepath.ToSlash(wd) + "/plz-out/gen/src/core" assert.Equal(t, root+"/file1.go", target.toolPath(true, "f1")) assert.Equal(t, "src/core/file1.go", target.toolPath(false, "f1")) } @@ -626,7 +630,12 @@ func TestAllURLs(t *testing.T) { func TestCheckSecrets(t *testing.T) { target := makeTarget1("//src/core:target1", "") assert.NoError(t, target.CheckSecrets()) - target.Secrets = append(target.Secrets, "/bin/sh") + // A file that exists, made rather than assumed. This used to be /bin/sh, which Windows + // does not have - and which passed under Wine anyway, because its Z: drive maps the host's + // root, so the test proved nothing there and failed on a real machine. + existing := filepath.Join(t.TempDir(), "a_secret") + require.NoError(t, os.WriteFile(existing, []byte("shhh"), 0644)) + target.Secrets = append(target.Secrets, existing) assert.NoError(t, target.CheckSecrets()) // Checking for files in the home directory is awkward because nothing is really // guaranteed to exist. We just check the directory itself for now. diff --git a/src/core/command_replacements.go b/src/core/command_replacements.go index 07f1b98e67..4d7dc818f2 100644 --- a/src/core/command_replacements.go +++ b/src/core/command_replacements.go @@ -55,6 +55,7 @@ package core import ( "encoding/base64" "fmt" + "path" "path/filepath" "runtime/debug" "strings" @@ -220,12 +221,12 @@ func replaceSequence(state *BuildState, target *BuildTarget, in string, runnable } } if hash { - return base64.RawURLEncoding.EncodeToString(state.PathHasher.MustHash(filepath.Join(target.Label.PackageName, in), target.HashLastModified())) + return base64.RawURLEncoding.EncodeToString(state.PathHasher.MustHash(path.Join(target.Label.PackageName, in), target.HashLastModified())) } if strings.HasPrefix(in, "/") { return in // Absolute path, probably on a tool or system src. } - return quote(filepath.Join(target.Label.PackageName, in)) + return quote(path.Join(target.Label.PackageName, in)) } // replaceWorkerSequence is like replaceSequence but for worker commands, which do not @@ -288,7 +289,9 @@ func checkAndReplaceSequence(state *BuildState, target, dep *BuildTarget, ep, in if err != nil { log.Fatalf("Couldn't calculate relative path: %s", err) } - outputBuilder.WriteString(quote(abs)) + // ToSlash because the absolute part comes from the OS: this is going + // straight into a shell command. + outputBuilder.WriteString(quote(filepath.ToSlash(abs))) } else { outputBuilder.WriteString(quote(fileDestination(target, dep, out, dir, outPrefix, test))) } @@ -327,9 +330,10 @@ func quote(s string) string { } // handleDir chooses either the out dir or the actual output location depending on the 'dir' flag. +// path, not filepath: the result is interpolated into a shell command. func handleDir(outDir, output string, dir bool) string { if dir { return outDir } - return filepath.Join(outDir, output) + return path.Join(outDir, output) } diff --git a/src/core/command_replacements_test.go b/src/core/command_replacements_test.go index 4187278cdf..f57e4b8260 100644 --- a/src/core/command_replacements_test.go +++ b/src/core/command_replacements_test.go @@ -108,7 +108,7 @@ func TestToolReplacement(t *testing.T) { target1.Tools = append(target1.Tools, target2.Label) wd, _ := os.Getwd() - expected := quote(filepath.Join(wd, "plz-out/gen/path/to/target2.py")) + expected := quote(filepath.ToSlash(filepath.Join(wd, "plz-out/gen/path/to/target2.py"))) cmd, _ := ReplaceSequences(state, target1, target1.Command) assert.Equal(t, expected, cmd) } @@ -119,7 +119,7 @@ func TestToolReplacementSubrepo(t *testing.T) { target1.Tools = append(target1.Tools, target2.Label) wd, _ := os.Getwd() - expected := quote(filepath.Join(wd, "plz-out/gen/subrepo/path/to/target2.py")) + expected := quote(filepath.ToSlash(filepath.Join(wd, "plz-out/gen/subrepo/path/to/target2.py"))) cmd, _ := ReplaceSequences(state, target1, target1.Command) assert.Equal(t, expected, cmd) } @@ -151,7 +151,7 @@ func TestToolDirReplacement(t *testing.T) { target1.Tools = append(target1.Tools, target2.Label) wd, _ := os.Getwd() - expected := quote(filepath.Join(wd, "plz-out/gen/path/to")) + expected := quote(filepath.ToSlash(filepath.Join(wd, "plz-out/gen/path/to"))) cmd, _ := ReplaceSequences(state, target1, target1.Command) assert.Equal(t, expected, cmd) } @@ -191,7 +191,7 @@ func TestWorkerReplacement(t *testing.T) { target.Tools = append(target.Tools, tool.Label) worker, remoteArgs, localCmd, err := WorkerCommandAndArgs(state, target) assert.NoError(t, err) - assert.Equal(t, wd+"/plz-out/bin/path/to/target2.py", worker) + assert.Equal(t, filepath.ToSlash(wd)+"/plz-out/bin/path/to/target2.py", worker) assert.Equal(t, "--some_arg", remoteArgs) assert.Equal(t, "", localCmd) } @@ -213,7 +213,7 @@ func TestLocalCommandWorker(t *testing.T) { target.Tools = append(target.Tools, tool.Label) worker, remoteArgs, localCmd, err := WorkerCommandAndArgs(state, target) assert.NoError(t, err) - assert.Equal(t, wd+"/plz-out/bin/path/to/target2.py", worker) + assert.Equal(t, filepath.ToSlash(wd)+"/plz-out/bin/path/to/target2.py", worker) assert.Equal(t, "--some_arg", remoteArgs) assert.Equal(t, "find . | xargs rm && echo hello", localCmd) } diff --git a/src/core/config.go b/src/core/config.go index 7076c3b334..fd4992cdcc 100644 --- a/src/core/config.go +++ b/src/core/config.go @@ -9,6 +9,7 @@ import ( iofs "io/fs" "maps" "os" + "os/exec" "path/filepath" "reflect" "runtime" @@ -28,6 +29,7 @@ import ( "github.com/thought-machine/please/src/cli" "github.com/thought-machine/please/src/fs" "github.com/thought-machine/please/src/metrics" + "github.com/thought-machine/please/src/process" "github.com/thought-machine/please/src/version" ) @@ -50,7 +52,8 @@ const LocalConfigFileName string = ".plzconfig.local" // MachineConfigFileName is the file name for the machine-level config - can use this to override // things for a particular machine (e.g. build machine with different caching behaviour). -const MachineConfigFileName = "/etc/please/plzconfig" +// This is platform-specific; see config_other.go and config_windows.go. +var MachineConfigFileName = machineConfigFileName // UserConfigFileName is the file name for user-specific config (for all their repos). const UserConfigFileName = "~/.config/please/plzconfig" @@ -58,8 +61,9 @@ const UserConfigFileName = "~/.config/please/plzconfig" // DefaultPleaseLocation is the default location where Please is installed. const DefaultPleaseLocation = "~/.please" -// DefaultPath is the default location please looks for programs in -var DefaultPath = []string{"/usr/local/bin", "/usr/bin", "/bin"} +// DefaultPath is the default location please looks for programs in. +// This is platform-specific; see config_other.go and config_windows.go. +var DefaultPath = defaultPath // readConfigFileOnly reads a single config file into the config struct func readConfigFileOnly(fs iofs.FS, config *Configuration, filename string, quiet bool) error { @@ -80,7 +84,7 @@ func readConfigFileOnly(fs iofs.FS, config *Configuration, filename string, quie } if gcfg.FatalOnly(err) != nil { - return err + return configError(filename, err) } if quiet { log.Debug("Error in config file %s: %s", filename, err) @@ -90,6 +94,17 @@ func readConfigFileOnly(fs iofs.FS, config *Configuration, filename string, quie return nil } +// configError names the file a config error came from, and for the one mistake people are most +// likely to make on Windows says what to do about it. A backslash starts an escape sequence in +// this format, so a path written the way Windows writes it fails to parse, with a message that +// gives no hint that a path is even involved. +func configError(filename string, err error) error { + if strings.Contains(err.Error(), `unquoted '\'`) { + return fmt.Errorf("%s: %w\nA backslash begins an escape sequence here; write paths with forward slashes, which Windows accepts too, or put the value in double quotes", filename, err) + } + return fmt.Errorf("%s: %w", filename, err) +} + // readConfigFile reads a single config file into the config struct taking into account // some context like subrepos and plugins. func readConfigFile(fs iofs.FS, config *Configuration, filename string, subrepo bool) error { @@ -154,7 +169,7 @@ func defaultGlobalConfigFiles() []string { } if xdgConfigDirs := os.Getenv("XDG_CONFIG_DIRS"); xdgConfigDirs != "" { - for _, p := range strings.Split(xdgConfigDirs, ":") { + for _, p := range fs.SplitPathList(xdgConfigDirs) { if !filepath.IsAbs(p) { continue } @@ -212,12 +227,15 @@ func ReadConfigFiles(fs iofs.FS, filenames []string, profiles []string) (*Config } } + // Resolve the full path to Please's own location. This has to happen before the plugin + // repo defaults below, which are relative to it. It is idempotent, and the call further + // down is left alone. + config.EnsurePleaseLocation() + // Set default values for slices. These add rather than overwriting so we can't set // them upfront as we would with other config values. - setDefault(&config.Please.PluginRepo, - "https://github.com/{owner}/{plugin}/archive/{revision}.zip", - "https://github.com/{owner}/{plugin}-rules/archive/{revision}.zip", - ) + setDefault(&config.Please.PluginRepo, config.defaultPluginRepos()...) + config.useBundledTools() if usingBazelWorkspace { setDefault(&config.Parse.BuildFileName, "BUILD.bazel", "BUILD", "BUILD.plz") } else { @@ -225,6 +243,7 @@ func ReadConfigFiles(fs iofs.FS, filenames []string, profiles []string) (*Config } setBuildPath(&config.Build.Path, config.Build.PassEnv, config.Build.PassUnsafeEnv) setDefault(&config.Build.HashCheckers, "sha1", "sha256", "blake3") + setDefault(&config.Build.ShellArgs, process.DefaultShellArgs...) setDefault(&config.Build.PassUnsafeEnv) setDefault(&config.Build.PassEnv) setDefault(&config.Cover.FileExtension, ".go", ".py", ".java", ".tsx", ".ts", ".js", ".cc", ".h", ".c", ".rs") @@ -288,9 +307,6 @@ func ReadConfigFiles(fs iofs.FS, filenames []string, profiles []string) (*Config } } - // Resolve the full path to its location. - config.EnsurePleaseLocation() - // If the HTTP proxy config is set and there is no env var overriding it, set it now // so various other libraries will honour it. if config.Build.HTTPProxy != "" { @@ -347,12 +363,12 @@ func setBuildPath(conf *[]string, passEnv []string, passUnsafeEnv []string) { pathVal := DefaultPath for _, i := range passUnsafeEnv { if i == "PATH" { - pathVal = strings.Split(os.Getenv("PATH"), ":") + pathVal = fs.SplitPathList(os.Getenv("PATH")) } } for _, i := range passEnv { if i == "PATH" { - pathVal = strings.Split(os.Getenv("PATH"), ":") + pathVal = fs.SplitPathList(os.Getenv("PATH")) } } setDefault(conf, pathVal...) @@ -372,7 +388,7 @@ func defaultPathIfExists(conf *string, dir, file string) { // DefaultConfiguration returns the default configuration object with no overrides. // N.B. Slice fields are not populated by this (since it interferes with reading them) func DefaultConfiguration() *Configuration { - config := Configuration{buildEnvStored: &storedBuildEnv{}} + config := Configuration{buildEnvStored: &storedBuildEnv{}, shellStored: &storedShell{}} config.Please.SelfUpdate = true config.Please.Autoclean = true config.Please.DownloadLocation = "https://get.please.build" @@ -386,9 +402,10 @@ func DefaultConfiguration() *Configuration { config.Build.Timeout = cli.Duration(10 * time.Minute) config.Build.Config = "opt" // Optimised builds by default config.Build.FallbackConfig = "opt" // Optimised builds as a fallback on any target that doesn't have a matching one set - config.Build.Xattrs = true + config.Build.Xattrs = defaultXattrs config.Build.HashFunction = "sha256" config.Build.ParallelDownloads = 4 + config.Build.Shell = process.DefaultShell config.BuildConfig = map[string]string{} config.BuildEnv = map[string]string{} config.Cache.HTTPWriteable = true @@ -466,7 +483,7 @@ func DefaultConfiguration() *Configuration { config.Python.PexTool = "/////_please:please_pex" config.Java.JavacWorker = "/////_please:javac_worker" config.Java.JarCatTool = "/////_please:arcat" - config.Build.ArcatTool = "/////_please:arcat" + config.Build.ArcatTool = DefaultArcatTool config.Java.JUnitRunner = "/////_please:junit_runner" config.Metrics.Timeout = cli.Duration(2 * time.Second) @@ -528,6 +545,8 @@ type Configuration struct { UpdateGitignore bool `help:"Whether to automatically update the nearest gitignore with generated sources"` ParallelDownloads int `help:"Max number of remote_file downloads to run in parallel."` ArcatTool string `help:"Defines the tool used to concatenate files which we use in various build rules. Defaults to Arcat." var:"ARCAT_TOOL"` + Shell string `help:"The shell that build actions and tests are run in. Defaults to 'bash', which is looked up on Please's PATH; on Windows it defaults to the busybox that Please bundles, since Windows has no system shell that can run a build action." example:"bash | /bin/sh"` + ShellArgs []string `help:"Arguments passed to the shell before the command to run. Defaults to --noprofile and --norc, which stop bash reading the invoking user's startup files. On Windows the default is 'bash', selecting busybox's shell applet; busybox reads no startup files and rejects those two flags. Note that -u, -o pipefail and (where applicable) -e are always passed and are not configurable here."` } `help:"A config section describing general settings related to building targets in Please.\nSince Please is by nature about building things, this only has the most generic properties; most of the more esoteric properties are configured in their own sections."` BuildConfig map[string]string `help:"A section of arbitrary key-value properties that are made available in the BUILD language. These are often useful for writing custom rules that need some configurable property.\n\n[buildconfig]\nandroid-tools-version = 23.0.2\n\nFor example, the above can be accessed as CONFIG.ANDROID_TOOLS_VERSION."` BuildEnv map[string]string `help:"A set of extra environment variables to define for build rules. For example:\n\n[buildenv]\nsecret-passphrase = 12345\n\nThis would become SECRET_PASSPHRASE for any rules. These can be useful for passing secrets into custom rules; any variables containing SECRET or PASSWORD won't be logged.\n\nIt's also useful if you'd like internal tools to honour some external variable."` @@ -691,6 +710,8 @@ type Configuration struct { // buildEnvStored is a cached form of BuildEnv. buildEnvStored *storedBuildEnv + // shellStored is a cached form of Shell(). + shellStored *storedShell FeatureFlags struct { } `help:"Flags controlling preview features for the next release. Typically these config options gate breaking changes and only have a lifetime of one major release."` @@ -742,6 +763,62 @@ type storedBuildEnv struct { Once sync.Once } +type storedShell struct { + Shell string + Once sync.Once +} + +// Shell returns the shell that build actions, tests and the command cache run in. +// +// A bare name is left for the OS to resolve on Please's own PATH, as it always has been. The +// exception is when it isn't there at all: then we look on the build path, which includes +// Please's own install directory. That is how the shell Please bundles on Windows gets found, +// since nothing puts that directory on the user's PATH. +func (config *Configuration) Shell() string { + if config.shellStored == nil { + // A Configuration built by hand rather than through DefaultConfiguration; nothing to + // cache in, so just work it out each time. + return config.resolveShell() + } + config.shellStored.Once.Do(func() { + config.shellStored.Shell = config.resolveShell() + }) + return config.shellStored.Shell +} + +// ShellArgs returns the arguments passed to the shell before the command itself. +// A Configuration built by hand has none set - the defaults for a repeatable key can only be +// applied after parsing, or they would be appended to rather than replaced - so the platform +// default stands in. +func (config *Configuration) ShellArgs() []string { + if len(config.Build.ShellArgs) == 0 { + return process.DefaultShellArgs + } + return config.Build.ShellArgs +} + +func (config *Configuration) resolveShell() string { + shell := config.Build.Shell + if shell == "" { + return process.DefaultShell + } else if filepath.IsAbs(shell) || strings.ContainsRune(shell, filepath.Separator) { + return shell + } else if _, err := exec.LookPath(shell); err == nil { + return shell + } else if path, err := LookPath(shell, config.Path()); err == nil { + return path + } else if exe, err := fs.Executable(); err == nil { + // Last resort: next to the binary that is running. That is where a bundled shell sits + // in an install, and unlike the build path above it doesn't depend on Please.Location + // having been resolved yet. + if path, err := LookPath(shell, []string{filepath.Dir(exe)}); err == nil { + return path + } + } + // Leave it as it is; the exec will fail with a better message than anything we'd write. + return shell +} + // Hash returns a hash of the parts of this configuration that affect building targets in general. // Most parts are considered not to (e.g. cache settings) or affect specific targets (e.g. changing // tool paths which get accounted for on the targets that use them). @@ -771,12 +848,47 @@ func (config *Configuration) GetBuildEnv() BuildEnv { config.buildEnvStored.Once.Do(func() { config.buildEnvStored.Env = config.getBuildEnv(true, true) if path, present := config.buildEnvStored.Env["PATH"]; present { - config.buildEnvStored.Path = strings.Split(path, ":") + config.buildEnvStored.Path = fs.SplitPathList(path) } }) return config.buildEnvStored.Env } +// DefaultArcatTool is the [build] arcattool that means "whichever one Please downloads". +// parse.ArcatUnavailable recognises it, to say something useful on a platform where there is +// nothing to download. +// The literal is parse.InternalPackageName, which core cannot import; parse asserts they agree. +const DefaultArcatTool = "/////_please:arcat" + +// defaultPluginRepos returns the templates a plugin_repo() is resolved against when nothing is +// configured. Setting any [please] pluginrepo replaces the whole list, as it always has. +func (config *Configuration) defaultPluginRepos() []string { + return []string{ + "https://github.com/{owner}/{plugin}/archive/{revision}.zip", + "https://github.com/{owner}/{plugin}-rules/archive/{revision}.zip", + } +} + +// useBundledTools points the config at any helper tool the release bundles beside the binary, +// where nothing else has been configured. +// +// This is only arcat. The plugins' own tools - please_go, please_cc, please_pex - are chosen by +// the plugins' build defs, because a plugin's config is not ours to default. +// +// Only Windows bundles anything. Everywhere else there is a published arcat to download, and +// the internal package rule is the better answer because it is hashed and cached like anything +// else. +func (config *Configuration) useBundledTools() { + if runtime.GOOS != "windows" || config.Build.ArcatTool != DefaultArcatTool { + return + } + if fs.FileExists(filepath.Join(config.Please.Location, "arcat"+fs.ExeSuffix)) { + // A bare name rather than a path: Please.Location is already the head of the build + // PATH, and the lookup adds the .exe. The same route the bundled busybox takes. + config.Build.ArcatTool = "arcat" + } +} + // EnsurePleaseLocation will resolve `config.Please.Location` to a full path location where it is to be found. func (config *Configuration) EnsurePleaseLocation() { defaultPleaseLocation := fs.ExpandHomePath(DefaultPleaseLocation) @@ -823,7 +935,7 @@ func (config *Configuration) getBuildEnv(includePath bool, includeUnsafe bool) B if v, isSet := os.LookupEnv(k); isSet { if k == "PATH" { // plz's install location always needs to be on the path. - v = config.Please.Location + ":" + v + v = config.Please.Location + string(os.PathListSeparator) + v includePath = false // skip this in a bit } env[k] = v @@ -842,7 +954,7 @@ func (config *Configuration) getBuildEnv(includePath bool, includeUnsafe bool) B // but really external environment variables shouldn't affect this. // The only concession is that ~ is expanded as the user's home directory // in PATH entries. - env["PATH"] = strings.Join(append([]string{config.Please.Location}, config.Build.Path...), ":") + env["PATH"] = strings.Join(append([]string{config.Please.Location}, config.Build.Path...), string(os.PathListSeparator)) } return env } diff --git a/src/core/config_other.go b/src/core/config_other.go new file mode 100644 index 0000000000..3637395b39 --- /dev/null +++ b/src/core/config_other.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package core + +const machineConfigFileName = "/etc/please/plzconfig" + +var defaultPath = []string{"/usr/local/bin", "/usr/bin", "/bin"} diff --git a/src/core/config_test.go b/src/core/config_test.go index d4d047ebf6..54c2f205e3 100644 --- a/src/core/config_test.go +++ b/src/core/config_test.go @@ -3,6 +3,7 @@ package core import ( "bytes" "os" + "path/filepath" "reflect" "strings" "testing" @@ -17,7 +18,9 @@ import ( ) func TestPlzConfigWorking(t *testing.T) { - RepoRoot = "/repo/root" + // A genuinely absolute path: on Windows a leading slash isn't one without a drive letter, + // so the location below would be resolved relative to the repo root a second time. + RepoRoot = filepath.Join(t.TempDir(), "repo", "root") config, err := ReadConfigFiles(fs.HostFS, []string{"src/core/test_data/working.plzconfig"}, nil) assert.NoError(t, err) @@ -28,7 +31,16 @@ func TestPlzConfigWorking(t *testing.T) { assert.Equal(t, "8", config.Java.SourceLevel) assert.Equal(t, "7", config.Java.TargetLevel) assert.Equal(t, "10", config.Java.ReleaseLevel) - assert.Equal(t, "/repo/root/plz-out/please", config.Please.Location) + assert.Equal(t, filepath.Join(RepoRoot, "plz-out", "please"), config.Please.Location) +} + +func TestPlzConfigBackslash(t *testing.T) { + // The mistake a Windows user makes first. The parser's own message says nothing about + // paths, so check we name the file and say what to do instead. + _, err := ReadConfigFiles(fs.HostFS, []string{"src/core/test_data/backslash.plzconfig"}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "backslash.plzconfig") + assert.Contains(t, err.Error(), "forward slashes") } func TestPlzConfigFailing(t *testing.T) { @@ -157,22 +169,26 @@ func TestConfigOverrideOptions(t *testing.T) { } func TestPleaseRelativeLocationOverride(t *testing.T) { - RepoRoot = "/repo/root" + RepoRoot = filepath.Join(t.TempDir(), "repo", "root") config := DefaultConfiguration() err := config.ApplyOverrides(map[string]string{"please.location": "./plz-out/please"}) assert.NoError(t, err) - assert.Equal(t, "/repo/root/plz-out/please", config.Please.Location) + assert.Equal(t, filepath.Join(RepoRoot, "plz-out", "please"), config.Please.Location) } func TestPleaseTildeLocationOverride(t *testing.T) { - t.Setenv("HOME", "/path/to/home") + // USERPROFILE as well as HOME: os.UserHomeDir reads the former on Windows. + home := filepath.Join(t.TempDir(), "home") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) config := DefaultConfiguration() err := config.ApplyOverrides(map[string]string{"please.location": "~/please-location"}) assert.NoError(t, err) - assert.Equal(t, "/path/to/home/please-location", config.Please.Location) + // Only the ~ is substituted, so the separator the user wrote survives as they wrote it. + assert.Equal(t, home+"/please-location", config.Please.Location) } func TestReadSemver(t *testing.T) { @@ -258,13 +274,20 @@ func TestUnknownHashChecker(t *testing.T) { assert.Error(t, err) } +// buildPath returns the PATH a config should produce: Please's own location, then the build +// path. Both of those are platform-specific - there is no default build path at all on Windows +// - so it's computed rather than written out. +func buildPath(config *Configuration) string { + return strings.Join(append([]string{config.Please.Location}, config.Build.Path...), string(os.PathListSeparator)) +} + func TestBuildEnvSection(t *testing.T) { config, err := ReadConfigFiles(fs.HostFS, []string{"src/core/test_data/buildenv.plzconfig"}, nil) assert.NoError(t, err) expected := BuildEnv{ "BAR_BAR": "first", "FOO_BAR": "second", - "PATH": os.Getenv("TMP_DIR") + ":/usr/local/bin:/usr/bin:/bin", + "PATH": buildPath(config), } assert.EqualValues(t, expected, config.GetBuildEnv()) } @@ -277,7 +300,7 @@ func TestPassEnv(t *testing.T) { expected := BuildEnv{ "BAR": "second", "FOO": "first", - "PATH": os.Getenv("TMP_DIR") + ":" + os.Getenv("PATH"), + "PATH": buildPath(config), } assert.EqualValues(t, expected, config.GetBuildEnv()) } @@ -290,7 +313,7 @@ func TestPassUnsafeEnv(t *testing.T) { expected := BuildEnv{ "BAR": "second", "FOO": "first", - "PATH": os.Getenv("TMP_DIR") + ":" + os.Getenv("PATH"), + "PATH": buildPath(config), } assert.EqualValues(t, expected, config.GetBuildEnv()) } @@ -316,7 +339,7 @@ func TestPassUnsafeEnvExcludedFromHash(t *testing.T) { func TestBuildPathWithPathEnv(t *testing.T) { config, err := ReadConfigFiles(fs.HostFS, []string{"src/core/test_data/passenv.plzconfig"}, nil) assert.NoError(t, err) - assert.Equal(t, config.Build.Path, strings.Split(os.Getenv("PATH"), ":")) + assert.Equal(t, config.Build.Path, fs.SplitPathList(os.Getenv("PATH"))) } func TestUpdateArgsWithAliases(t *testing.T) { @@ -415,25 +438,32 @@ func TestGetTags(t *testing.T) { } func TestEnsurePleaseLocation(t *testing.T) { - t.Setenv("HOME", "/path/to/home") + // The home directory is read through os.UserHomeDir, which looks at a different variable + // on Windows, and the paths below have to be genuinely absolute to be recognised as such + // there - a leading slash isn't enough without a drive letter. + home := filepath.Join(t.TempDir(), "home") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) config := DefaultConfiguration() // Empty please location config resolves to this executable's directory config.Please.Location = "" config.EnsurePleaseLocation() - assert.Equal(t, os.Getenv("PWD"), config.Please.Location) + wd, err := os.Getwd() + require.NoError(t, err) + assert.Equal(t, wd, config.Please.Location) // Expands ~ config.Please.Location = "~" config.EnsurePleaseLocation() - assert.Equal(t, "/path/to/home", config.Please.Location) + assert.Equal(t, home, config.Please.Location) // Resolves relative path to repo root - RepoRoot = "/repo/root" + RepoRoot = filepath.Join(t.TempDir(), "repo", "root") config.Please.Location = "./plz-out/please" config.EnsurePleaseLocation() - assert.Equal(t, "/repo/root/plz-out/please", config.Please.Location) + assert.Equal(t, filepath.Join(RepoRoot, "plz-out", "please"), config.Please.Location) } func TestPluginConfig(t *testing.T) { @@ -441,3 +471,23 @@ func TestPluginConfig(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"fooc"}, config.Plugin["foo"].ExtraValues["fooctool"]) } + +func TestDefaultPluginRepos(t *testing.T) { + // The URL list is hashed into every plugin download's rule hash, so an entry added here + // moves build hashes on every platform at once. It is the same list everywhere, and was + // briefly not: a Windows release used to carry its plugins and point at them with a + // file:// template, which is gone now that they are downloadable like anything else. + repos := DefaultConfiguration().defaultPluginRepos() + assert.Len(t, repos, 2) + for _, repo := range repos { + assert.True(t, strings.HasPrefix(repo, "https://github.com/"), repo) + } +} + +func TestUseBundledToolsLeavesAConfiguredArcatAlone(t *testing.T) { + config := DefaultConfiguration() + config.Please.Location = "/opt/please" + config.Build.ArcatTool = "//my/own:arcat" + config.useBundledTools() + assert.Equal(t, "//my/own:arcat", config.Build.ArcatTool) +} diff --git a/src/core/config_windows.go b/src/core/config_windows.go new file mode 100644 index 0000000000..f35f2837de --- /dev/null +++ b/src/core/config_windows.go @@ -0,0 +1,22 @@ +package core + +import ( + "os" + "path/filepath" +) + +// machineConfigFileName lives under ProgramData, which is the Windows equivalent of /etc for +// machine-wide configuration. If the variable isn't set we fall back to the conventional path. +var machineConfigFileName = filepath.Join(programData(), "please", "plzconfig") + +// defaultPath is deliberately empty. There is no Windows equivalent of /usr/bin holding the +// tools a build might need, and the conventional locations (System32 and friends) hold none +// of them, so there is nothing useful to default to; users configure [build] path instead. +var defaultPath []string + +func programData() string { + if dir := os.Getenv("ProgramData"); dir != "" { + return dir + } + return `C:\ProgramData` +} diff --git a/src/core/lock.go b/src/core/lock.go index 629cb12e5b..ec99e511c3 100644 --- a/src/core/lock.go +++ b/src/core/lock.go @@ -1,4 +1,4 @@ -// The logic below relies heavily on flock (advisory locks). +// The logic below relies heavily on advisory file locking; see lock_other.go and lock_windows.go. package core @@ -6,7 +6,6 @@ import ( "fmt" "os" "strconv" - "syscall" "github.com/thought-machine/please/src/fs" ) @@ -25,7 +24,7 @@ var repoLockFile *os.File // AcquireSharedRepoLock acquires a shared lock on the repo lock file. The file descriptor is reused if already opened // allowing its lock mode to be replaced. Dies if the lock cannot be successfully acquired. func AcquireSharedRepoLock() { - if err := acquireRepoLock(syscall.LOCK_SH); err != nil { + if err := acquireRepoLock(lockShared); err != nil { log.Fatal(err) } } @@ -33,7 +32,7 @@ func AcquireSharedRepoLock() { // AcquireExclusiveRepoLock acquires an exclusive lock on the repo lock file. The file descriptor is reused if already opened // allowing its lock mode to be replaced. Dies if the lock cannot be successfully acquired. func AcquireExclusiveRepoLock() { - if err := acquireRepoLock(syscall.LOCK_EX); err != nil { + if err := acquireRepoLock(lockExclusive); err != nil { log.Fatal(err) } } @@ -75,13 +74,13 @@ func openRepoLockFile() error { // AcquireExclusiveFileLock opens a file to acquire an exclusive lock. func AcquireExclusiveFileLock(filePath string) (*os.File, error) { - return acquireOpenFileLock(filePath, syscall.LOCK_EX) + return acquireOpenFileLock(filePath, lockExclusive) } // AcquireSharedFileLock opens a file to acquire a shared lock. // Multiple of these can be held at once, but not concurrently with an exclusive lock (ala a RWMutex or similar). func AcquireSharedFileLock(filePath string) (*os.File, error) { - return acquireOpenFileLock(filePath, syscall.LOCK_SH) + return acquireOpenFileLock(filePath, lockShared) } // Base function that allows to set up different lock modes and facilitate testing. @@ -105,7 +104,7 @@ func ReleaseFileLock(file *os.File) { return } - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_UN); err != nil { + if err := flock(file, lockUnlock); err != nil { log.Errorf("Failed to release lock for %s: %s", file.Name(), err) // No point making this fatal really } if err := file.Close(); err != nil { @@ -118,7 +117,7 @@ type logFunc func(format string, args ...interface{}) func acquireFileLock(file *os.File, how int, levelLog logFunc) error { // Try a non-blocking acquire first so we can warn the user if we're waiting. log.Debug("Attempting to acquire lock for %s...", file.Name()) - err := syscall.Flock(int(file.Fd()), how|syscall.LOCK_NB) + err := flock(file, how|lockNonBlocking) if err != nil { pid, err := os.ReadFile(file.Name()) if err == nil && len(pid) > 0 { @@ -127,14 +126,14 @@ func acquireFileLock(file *os.File, how int, levelLog logFunc) error { levelLog("Looks like another process has already acquired the lock for %s. Waiting for it to finish...", file.Name()) } - if err := syscall.Flock(int(file.Fd()), how); err != nil { + if err := flock(file, how); err != nil { return fmt.Errorf("Failed to acquire lock for %s: %w", file.Name(), err) } } log.Debug("Acquired lock for %s", file.Name()) // Record content, only if we have an exclusive lock. - if how&syscall.LOCK_EX != 0 { + if how&lockExclusive != 0 { if err := file.Truncate(0); err == nil { file.WriteAt([]byte(strconv.Itoa(os.Getpid())), 0) } diff --git a/src/core/lock_other.go b/src/core/lock_other.go new file mode 100644 index 0000000000..a5200c7389 --- /dev/null +++ b/src/core/lock_other.go @@ -0,0 +1,21 @@ +//go:build !windows +// +build !windows + +package core + +import ( + "os" + "syscall" +) + +const ( + lockShared = syscall.LOCK_SH + lockExclusive = syscall.LOCK_EX + lockUnlock = syscall.LOCK_UN + lockNonBlocking = syscall.LOCK_NB +) + +// flock applies or releases an advisory lock on an open file. +func flock(file *os.File, how int) error { + return syscall.Flock(int(file.Fd()), how) +} diff --git a/src/core/lock_test.go b/src/core/lock_test.go index 5940baeae7..38cecea2c2 100644 --- a/src/core/lock_test.go +++ b/src/core/lock_test.go @@ -3,7 +3,6 @@ package core import ( "os" "strconv" - "syscall" "testing" "github.com/stretchr/testify/assert" @@ -60,11 +59,11 @@ func TestAcquireExclusiveRepoRoot(t *testing.T) { } func TestAcquireRepoRootOverride(t *testing.T) { - err := acquireRepoLock(syscall.LOCK_SH | syscall.LOCK_NB) + err := acquireRepoLock(lockShared | lockNonBlocking) assert.NoError(t, err) // It is able to immediately override the lock mode since it uses the same file descriptor. - err = acquireRepoLock(syscall.LOCK_EX | syscall.LOCK_NB) + err = acquireRepoLock(lockExclusive | lockNonBlocking) assert.NoError(t, err) ReleaseRepoLock() @@ -73,7 +72,7 @@ func TestAcquireRepoRootOverride(t *testing.T) { // This attempts to mimic how 2 plz processes acquire a shared repo lock. func TestAcquireSharedRepoRootTwice(t *testing.T) { // 1st process. - err := acquireRepoLock(syscall.LOCK_SH | syscall.LOCK_NB) + err := acquireRepoLock(lockShared | lockNonBlocking) assert.NoError(t, err) // Keep file descriptor reference alive. @@ -83,7 +82,7 @@ func TestAcquireSharedRepoRootTwice(t *testing.T) { // 2nd process. repoLockFile = nil // Reset. // It is able to immediately acquire another shared lock via a different file descriptor. - err = acquireRepoLock(syscall.LOCK_SH | syscall.LOCK_NB) + err = acquireRepoLock(lockShared | lockNonBlocking) assert.NoError(t, err) ReleaseRepoLock() @@ -92,7 +91,7 @@ func TestAcquireSharedRepoRootTwice(t *testing.T) { // This attempts to mimic how 1 plz process acquires a shared repo lock and another tries to acquire an exclusive one. func TestAcquireSharedAndExclusiveRepoRoot(t *testing.T) { // 1st process. - err := acquireRepoLock(syscall.LOCK_SH | syscall.LOCK_NB) + err := acquireRepoLock(lockShared | lockNonBlocking) assert.NoError(t, err) // Keep file descriptor reference alive. @@ -102,7 +101,7 @@ func TestAcquireSharedAndExclusiveRepoRoot(t *testing.T) { // 2nd process. repoLockFile = nil // Reset. // It errors immediately trying to acquire an exclusive lock as a shared one already exists from process 1. - err = acquireRepoLock(syscall.LOCK_EX | syscall.LOCK_NB) + err = acquireRepoLock(lockExclusive | lockNonBlocking) assert.Error(t, err) ReleaseRepoLock() @@ -111,7 +110,7 @@ func TestAcquireSharedAndExclusiveRepoRoot(t *testing.T) { // This attempts to mimic how 1 plz process acquires an exclusive repo lock and another tries to acquire a shared one. func TestAcquireExclusiveAndSharedRepoRoot(t *testing.T) { // 1st process. - err := acquireRepoLock(syscall.LOCK_EX | syscall.LOCK_NB) + err := acquireRepoLock(lockExclusive | lockNonBlocking) assert.NoError(t, err) // Keep file descriptor reference alive. @@ -121,7 +120,7 @@ func TestAcquireExclusiveAndSharedRepoRoot(t *testing.T) { // 2nd process. repoLockFile = nil // Reset. // It errors immediately trying to acquire a shared lock as an exclusive one already exists from process 1. - err = acquireRepoLock(syscall.LOCK_SH | syscall.LOCK_NB) + err = acquireRepoLock(lockShared | lockNonBlocking) assert.Error(t, err) ReleaseRepoLock() @@ -149,7 +148,7 @@ func TestAcquireExclusiveFileLock(t *testing.T) { // This attempts to mimic how 1 plz process acquires an exclusive file lock and another tries to do the same thing to the same file. func TestAcquireExclusiveFileLockTwice(t *testing.T) { // 1st process. - fd1, err := acquireOpenFileLock("path/to/file", syscall.LOCK_EX|syscall.LOCK_NB) + fd1, err := acquireOpenFileLock("path/to/file", lockExclusive|lockNonBlocking) assert.NoError(t, err) // Keep file descriptor reference alive. @@ -158,7 +157,7 @@ func TestAcquireExclusiveFileLockTwice(t *testing.T) { // 2nd process. // It errors immediately trying to acquire an exclusive lock as the same lock mode was already placed by process 1. - fd2, err := acquireOpenFileLock("path/to/file", syscall.LOCK_EX|syscall.LOCK_NB) + fd2, err := acquireOpenFileLock("path/to/file", lockExclusive|lockNonBlocking) assert.Error(t, err) ReleaseFileLock(fd2) diff --git a/src/core/lock_windows.go b/src/core/lock_windows.go new file mode 100644 index 0000000000..bf30fa6626 --- /dev/null +++ b/src/core/lock_windows.go @@ -0,0 +1,75 @@ +package core + +import ( + "os" + "sync" + + "golang.org/x/sys/windows" +) + +// These mirror the flock(2) constants; their values are arbitrary since Windows doesn't +// define them, but they must remain distinct bits because callers combine and test them. +const ( + lockShared = 0x1 + lockExclusive = 0x2 + lockUnlock = 0x8 + lockNonBlocking = 0x4 +) + +// LockFileEx locks a byte range rather than a whole file. We lock a single byte far past any +// plausible content so that the PID written into the lock file stays readable by other +// processes, which is what produces the "process N has already acquired the lock" message. +const ( + lockOffsetLow = 0 + lockOffsetHigh = 0x40000000 +) + +// Windows has no equivalent of flock's atomic conversion between shared and exclusive on a +// single handle, so we have to release before re-acquiring. Track what each handle holds in +// order to do that only when it's actually needed. +var ( + locksMux sync.Mutex + locks = map[*os.File]bool{} +) + +// flock applies or releases an advisory lock on an open file. +// +// N.B. unlike flock(2), changing mode on a handle that already holds a lock is not atomic: +// the lock is dropped and re-taken, so another process can take it in between. Please only +// changes mode at startup (shared -> exclusive in acquireRepoLock), so in practice this +// window is not contended. +func flock(file *os.File, how int) error { + handle := windows.Handle(file.Fd()) + overlapped := &windows.Overlapped{Offset: lockOffsetLow, OffsetHigh: lockOffsetHigh} + + locksMux.Lock() + defer locksMux.Unlock() + + if how&lockUnlock != 0 { + if !locks[file] { + return nil + } + delete(locks, file) + return windows.UnlockFileEx(handle, 0, 1, 0, overlapped) + } + + if locks[file] { + if err := windows.UnlockFileEx(handle, 0, 1, 0, overlapped); err != nil { + return err + } + delete(locks, file) + } + + var flags uint32 + if how&lockExclusive != 0 { + flags |= windows.LOCKFILE_EXCLUSIVE_LOCK + } + if how&lockNonBlocking != 0 { + flags |= windows.LOCKFILE_FAIL_IMMEDIATELY + } + if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil { + return err + } + locks[file] = true + return nil +} diff --git a/src/core/package.go b/src/core/package.go index 2bca9a0160..4ab074c113 100644 --- a/src/core/package.go +++ b/src/core/package.go @@ -3,6 +3,7 @@ package core import ( "fmt" "maps" + "path" "path/filepath" "slices" "sort" @@ -267,12 +268,14 @@ func FindOwningPackages(state *BuildState, files []string) []BuildLabel { // FindOwningPackage returns a build label identifying the package that owns a given file. func FindOwningPackage(state *BuildState, file string) BuildLabel { - f := filepath.Dir(file) + // path, not filepath: the result becomes a build label package name, and filepath.Dir on + // Windows would hand back backslashes, which aren't legal in one. + f := path.Dir(filepath.ToSlash(file)) for f != "." { if fs.IsPackage(state.Config.Parse.BuildFileName, f) { return BuildLabel{PackageName: f, Name: "all"} } - f = filepath.Dir(f) + f = path.Dir(f) } return BuildLabel{PackageName: "", Name: "all"} } diff --git a/src/core/pathsep_other.go b/src/core/pathsep_other.go new file mode 100644 index 0000000000..114cdf2cf4 --- /dev/null +++ b/src/core/pathsep_other.go @@ -0,0 +1,7 @@ +//go:build !windows +// +build !windows + +package core + +// normalisePathSeparators is a no-op where the path separator is already a forward slash. +func (env BuildEnv) normalisePathSeparators() {} diff --git a/src/core/pathsep_windows.go b/src/core/pathsep_windows.go new file mode 100644 index 0000000000..cfdfe51f6b --- /dev/null +++ b/src/core/pathsep_windows.go @@ -0,0 +1,21 @@ +package core + +import "strings" + +// normalisePathSeparators rewrites the paths Please generates to use forward slashes. +// +// Build commands are shell strings, and a backslash is an escape character to much of what +// runs in them. Expanding a variable is safe, but passing one to anything that interprets its +// arguments is not: `sed -e "s#x#$TMP_DIR#"` silently turns `\tmp` into a tab, and the C/C++ +// rules build their link line with sed. Win32, MinGW and busybox all accept forward slashes, +// so we use those throughout. +// +// This deliberately runs before withUserProvidedEnv: values the user wrote themselves are +// left exactly as written, since they may not be paths at all. +func (env BuildEnv) normalisePathSeparators() { + for k, v := range env { + if strings.ContainsRune(v, '\\') { + env[k] = strings.ReplaceAll(v, `\`, `/`) + } + } +} diff --git a/src/core/platform_env_other.go b/src/core/platform_env_other.go new file mode 100644 index 0000000000..52d5cc2131 --- /dev/null +++ b/src/core/platform_env_other.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package core + +// setPlatformTmpEnv sets any platform-specific environment variables pointing at a build +// action's temporary directory. Unix tools use HOME and TMPDIR, which are set already. +func setPlatformTmpEnv(env BuildEnv, dir string) {} diff --git a/src/core/platform_env_windows.go b/src/core/platform_env_windows.go new file mode 100644 index 0000000000..cdf158ab98 --- /dev/null +++ b/src/core/platform_env_windows.go @@ -0,0 +1,11 @@ +package core + +// setPlatformTmpEnv sets any platform-specific environment variables pointing at a build +// action's temporary directory. Windows-native tools look at USERPROFILE rather than HOME, +// and at TEMP/TMP rather than TMPDIR, so they need the same redirection for the build +// environment to stay hermetic. +func setPlatformTmpEnv(env BuildEnv, dir string) { + env["USERPROFILE"] = dir + env["TEMP"] = dir + env["TMP"] = dir +} diff --git a/src/core/sandboxdir_other.go b/src/core/sandboxdir_other.go new file mode 100644 index 0000000000..064af19bce --- /dev/null +++ b/src/core/sandboxdir_other.go @@ -0,0 +1,6 @@ +//go:build !windows +// +build !windows + +package core + +const sandboxDir = "/tmp/plz_sandbox" diff --git a/src/core/sandboxdir_windows.go b/src/core/sandboxdir_windows.go new file mode 100644 index 0000000000..21a340ad4a --- /dev/null +++ b/src/core/sandboxdir_windows.go @@ -0,0 +1,5 @@ +package core + +// sandboxDir is unused for now - there is no sandbox on Windows (see M7 in +// docs/design/windows) - but it still has to be a path that never collides with a repo. +const sandboxDir = `C:\plz_sandbox` diff --git a/src/core/sandboxsupport_other.go b/src/core/sandboxsupport_other.go new file mode 100644 index 0000000000..c1f465bd5c --- /dev/null +++ b/src/core/sandboxsupport_other.go @@ -0,0 +1,9 @@ +//go:build !windows +// +build !windows + +package core + +// sandboxSupported reports whether this platform can isolate a build action at all. +// Everywhere but Windows there is at least a sandbox tool to hand the action to, even if what +// it does varies; on Linux it does the whole job. +func sandboxSupported() bool { return true } diff --git a/src/core/sandboxsupport_windows.go b/src/core/sandboxsupport_windows.go new file mode 100644 index 0000000000..209585b5fd --- /dev/null +++ b/src/core/sandboxsupport_windows.go @@ -0,0 +1,8 @@ +package core + +// sandboxSupported reports whether this platform can isolate a build action at all. +// +// Nothing on Windows does yet. The pieces exist - job objects, restricted tokens - but there +// is no analogue of a mount namespace, so filesystem isolation would need Windows Containers, +// which is far too large a dependency to take on. See docs/design/windows. +func sandboxSupported() bool { return false } diff --git a/src/core/state.go b/src/core/state.go index 685c6184a7..089d36838e 100644 --- a/src/core/state.go +++ b/src/core/state.go @@ -12,6 +12,7 @@ import ( iofs "io/fs" "iter" "path/filepath" + "runtime" "runtime/pprof" "sort" "strings" @@ -886,6 +887,19 @@ func (state *BuildState) SyncParsePackage(label BuildLabel) *Package { return state.Graph.PackageByLabel(label) // Important to check again; it's possible to race against this whole lot. } +// ReleasePendingParse gives back the claim on parsing a package that SyncParsePackage granted, +// for a package that has not been parsed and is not going to be. +// +// It exists for callers that parse a package speculatively - to find out whether it exists at +// all - and swallow the error when it doesn't. Such a caller still took the claim, and if it +// keeps it every later caller asking about the same package waits forever for a parse nobody is +// going to do. That shows up as a hang with no output rather than an error. +func (state *BuildState) ReleasePendingParse(label BuildLabel) { + if ch, present := state.progress.pendingPackages.Delete(label.packageKey()); present { + close(ch) // Anything already waiting goes back to trying for itself. + } +} + func waitOnChan[T any](ch chan T, message string, args ...any) { start := time.Now() t := time.NewTimer(10 * time.Second) @@ -1479,11 +1493,18 @@ func newXXHash() hash.Hash { } func executorFromConfig(config *Configuration) *process.Executor { + wantsSandbox := config.Sandbox.Build || config.Sandbox.Test + if wantsSandbox && !sandboxSupported() { + // Saying the tool is missing would be misleading here - there is nothing to install. + log.Warningf("Sandboxing is not implemented on %s; build actions and tests will run without isolation.", runtime.GOOS) + return process.NewSandboxingExecutor(false, process.NamespaceNever, "", config.Shell(), config.ShellArgs()) + } + tool := config.Sandbox.Tool if !filepath.IsAbs(tool) { var err error tool, err = LookBuildPath(tool, config) - if err != nil && (config.Sandbox.Build || config.Sandbox.Test) { + if err != nil && wantsSandbox { log.Warningf("Can't find sandbox tool %v on the path: %v", config.Sandbox.Tool, err) } } else if !fs.FileExists(tool) { @@ -1491,9 +1512,11 @@ func executorFromConfig(config *Configuration) *process.Executor { } return process.NewSandboxingExecutor( - config.Sandbox.Tool == "" && (config.Sandbox.Build || config.Sandbox.Test), + config.Sandbox.Tool == "" && wantsSandbox, process.NamespacingPolicy(config.Sandbox.Namespace), tool, + config.Shell(), + config.ShellArgs(), ) } diff --git a/src/core/subrepo.go b/src/core/subrepo.go index 100d4e0ab8..2713477a2d 100644 --- a/src/core/subrepo.go +++ b/src/core/subrepo.go @@ -4,7 +4,7 @@ import ( "fmt" iofs "io/fs" "os" - "path/filepath" + "path" "strings" "sync" @@ -110,8 +110,9 @@ func LabelToArch(label BuildLabel, arch cli.Arch) BuildLabel { } // Dir returns the directory for a package of this name. +// path, not filepath: subrepo roots are plz-out paths, which stay slash-separated. func (s *Subrepo) Dir(dir string) string { - return filepath.Join(s.Root, dir) + return path.Join(s.Root, dir) } func readSubrepoConfig(repoConfig *Configuration, subrepo *Subrepo) error { diff --git a/src/core/test_data/backslash.plzconfig b/src/core/test_data/backslash.plzconfig new file mode 100644 index 0000000000..9530a96298 --- /dev/null +++ b/src/core/test_data/backslash.plzconfig @@ -0,0 +1,2 @@ +[build] +path = C:\tools\bin diff --git a/src/core/test_results.go b/src/core/test_results.go index 727d9c533b..5756925f3a 100644 --- a/src/core/test_results.go +++ b/src/core/test_results.go @@ -3,7 +3,6 @@ package core import ( "bytes" "fmt" - "strings" "time" "github.com/thought-machine/please/src/fs" @@ -304,9 +303,7 @@ func MergeCoverageLines(existing, coverage []LineCoverage) []LineCoverage { func (coverage *TestCoverage) OrderedFiles() []string { files := make([]string, 0, len(coverage.Files)) for file := range coverage.Files { - if strings.HasPrefix(file, RepoRoot) { - file = strings.TrimLeft(file[len(RepoRoot):], "/") - } + file = TrimRepoRoot(file) files = append(files, file) } fs.SortPaths(files) diff --git a/src/core/utils.go b/src/core/utils.go index 3c63f53516..d429e4b659 100644 --- a/src/core/utils.go +++ b/src/core/utils.go @@ -6,6 +6,7 @@ import ( "fmt" "iter" "os" + "path" "path/filepath" "strings" @@ -20,7 +21,8 @@ var RepoRoot string var InitialWorkingDir string // InitialPackagePath is the initial subdir of the working directory, ie. what package did we start in. -// This is similar but not identical to InitialWorkingDir. +// This is similar but not identical to InitialWorkingDir. It is a build label package name, so it +// is always slash-separated, even on Windows. var InitialPackagePath string // usingBazelWorkspace is true if we detected a Bazel WORKSPACE file to find our repo root. @@ -78,7 +80,8 @@ func InitialPackage() []BuildLabel { label.Name = "..." return []BuildLabel{label} } - dir = filepath.Dir(dir) + // path, not filepath: this is a package name, which is slash-separated everywhere. + dir = path.Dir(dir) } return WholeGraph } @@ -89,18 +92,74 @@ func getRepoRoot(filename string) (string, string) { if err != nil { log.Fatalf("Couldn't determine working directory: %s", err) } - // Walk up directories looking for a .plzconfig file, which we use to identify the root. + return findRepoRootFrom(dir, filename) +} + +// findRepoRootFrom walks up from the given directory looking for the file that marks a repo +// root, and returns that directory and the package the walk started in. +func findRepoRootFrom(dir, filename string) (string, string) { initial := dir for dir != "" { if PathExists(filepath.Join(dir, filename)) { - return dir, strings.TrimLeft(initial[len(dir):], "/") + // The second return is a package name, so it has to come back slash-separated + // whatever the OS gave us - anything else fails build label validation, and the + // initial package silently becomes the whole repo. + return dir, strings.Trim(filepath.ToSlash(initial[len(dir):]), "/") } - dir, _ = filepath.Split(dir) - dir = strings.TrimRight(dir, "/") + // Stop when the walk stops going anywhere, rather than when it reaches an empty + // string. On Windows it never reaches one: trimming the separator off "C:\" leaves + // "C:", and splitting that returns it unchanged, because the volume name is the whole + // path. Before this, any plz run outside a repo spun here for ever, one stat per + // iteration, instead of reporting that it couldn't find a root. + parent, _ := filepath.Split(dir) + parent = strings.TrimRight(parent, fs.PathSeparators) + if parent == dir { + break + } + dir = parent } return "", "" } +// IsInRepoRoot returns true if the given path is inside the repo. +// +// It exists because comparing against RepoRoot directly is wrong on Windows. RepoRoot is in the +// OS's own separator, so it is backslashed there, while paths that arrive from outside - a +// file:// URL, a coverage report from another tool - are usually slash-separated. A plain +// HasPrefix then never matches, and a guard written that way silently stops guarding. +// +// It also only matches at a path boundary, so that /repo/elsewhere is not inside /repo/else. +func IsInRepoRoot(path string) bool { + _, ok := trimRepoRoot(path) + return ok +} + +// TrimRepoRoot returns the given path relative to the repo root, or unchanged if it is not +// inside it. The result keeps whatever separators it arrived with. +func TrimRepoRoot(path string) string { + if trimmed, ok := trimRepoRoot(path); ok { + return trimmed + } + return path +} + +func trimRepoRoot(path string) (string, bool) { + root := filepath.ToSlash(RepoRoot) + normalised := filepath.ToSlash(path) + if root == "" || !strings.HasPrefix(normalised, root) { + return path, false + } + rest := path[len(root):] + if rest == "" { + return "", true + } + // Only a match at a boundary; "/repo" is not a prefix of "/repository". + if !strings.ContainsRune(fs.PathSeparators, rune(rest[0])) && !strings.HasSuffix(root, "/") { + return path, false + } + return strings.TrimLeft(rest, fs.PathSeparators), true +} + // StartedAtRepoRoot returns true if the build was initiated from the repo root. // Used to provide slightly nicer output in some places. func StartedAtRepoRoot() bool { @@ -125,7 +184,9 @@ func IterSources(state *BuildState, graph *BuildGraph, target *BuildTarget, incl for input := range IterInputs(state, graph, target, includeTools, false) { fullPaths := input.FullPaths(graph) for i, sourcePath := range input.Paths(graph) { - if tmpPath := filepath.Join(tmpDir, sourcePath); !done[tmpPath] { + // path, not filepath: these are plz-out paths, and they reach build actions + // through the environment as $SRCS. + if tmpPath := path.Join(tmpDir, sourcePath); !done[tmpPath] { if !yield(fullPaths[i], tmpPath) { return } @@ -511,15 +572,29 @@ func CollapseHash(key []byte) []byte { // The main difference is that it looks based on our config which isn't necessarily the same // as the external environment variable. func LookPath(filename string, paths []string) (string, error) { + names := fs.ExecutableNames(filename) + dirs := 0 for _, p := range paths { - for _, p2 := range strings.Split(p, ":") { - p3 := filepath.Join(p2, filename) - if _, err := os.Stat(p3); err == nil { - return p3, nil + for _, p2 := range fs.SplitPathList(p) { + if p2 != "" { + dirs++ + } + for _, name := range names { + p3 := filepath.Join(p2, name) + if _, err := os.Stat(p3); err == nil { + return p3, nil + } } } } - return "", fmt.Errorf("%s not found in path %s", filename, strings.Join(paths, ":")) + err := fmt.Errorf("%s not found in path %s", filename, strings.Join(paths, string(os.PathListSeparator))) + if dirs <= 1 { + // Only Please's own directory was searched, which means no build path is configured. + // There is no default one on Windows - nothing there corresponds to /usr/bin - so this + // is the first thing a new user hits, and the message above doesn't hint at the answer. + return "", fmt.Errorf("%w\nNo [build] path is configured; set one to the directories your tools live in", err) + } + return "", err } // LookBuildPath is like LookPath but takes the config's build path into account. diff --git a/src/core/utils_test.go b/src/core/utils_test.go index adfa07dd63..3e8107c078 100644 --- a/src/core/utils_test.go +++ b/src/core/utils_test.go @@ -4,9 +4,15 @@ import ( "crypto/sha1" "encoding/base64" "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thought-machine/please/src/fs" ) func TestCollapseHash(t *testing.T) { @@ -118,29 +124,49 @@ func TestInitialPackageUpToRoot(t *testing.T) { assert.Equal(t, []BuildLabel{{PackageName: "", Name: "..."}}, p) } +// writeFakeTool creates an executable named tool, plus whatever extension the platform needs +// to consider it one, in a new directory, and returns the directory and the full path. +func writeFakeTool(t *testing.T, tool string) (string, string) { + t.Helper() + dir := t.TempDir() + file := filepath.Join(dir, tool+fs.ExeSuffix) + require.NoError(t, os.WriteFile(file, nil, 0o755)) + return dir, file +} + func TestLookPath(t *testing.T) { - // Assume this will be present on the path somewhere (you've really got to have bash for plz) - path, err := LookPath("bash", []string{"/usr/local/bin", "/usr/bin", "/bin"}) - assert.NoError(t, err) - assert.Contains(t, []string{"/usr/local/bin/bash", "/usr/bin/bash", "/bin/bash"}, path) - info, err := os.Stat(path) - assert.NoError(t, err) - assert.Equal(t, "bash", info.Name()) + // A tool we put there ourselves, rather than something the host is assumed to have: the + // directories Please looks in by default differ per platform, and on Windows there are none. + dir, file := writeFakeTool(t, "plz_look_path_test") + found, err := LookPath("plz_look_path_test", []string{filepath.Join(dir, "nonexistent"), dir}) + require.NoError(t, err) + assert.Equal(t, file, found) } func TestLookPathColons(t *testing.T) { - // We support having colons inside the path elements because people might find that more natural. - path, err := LookPath("bash", []string{"/usr/local/bin:/usr/bin:/bin"}) - assert.NoError(t, err) - assert.Contains(t, []string{"/usr/local/bin/bash", "/usr/bin/bash", "/bin/bash"}, path) - info, err := os.Stat(path) - assert.NoError(t, err) - assert.Equal(t, "bash", info.Name()) + // We support having the list separator inside the path elements because people might find + // that more natural. + dir, file := writeFakeTool(t, "plz_look_path_test") + joined := strings.Join([]string{filepath.Join(dir, "nonexistent"), dir}, string(os.PathListSeparator)) + found, err := LookPath("plz_look_path_test", []string{joined}) + require.NoError(t, err) + assert.Equal(t, file, found) } func TestLookPathDoesntExist(t *testing.T) { - _, err := LookPath("wibblewobbleflibble", []string{"/usr/local/bin", "/usr/bin", "/bin"}) + dir, _ := writeFakeTool(t, "plz_look_path_test") + _, err := LookPath("wibblewobbleflibble", []string{dir, t.TempDir()}) assert.Error(t, err) + assert.NotContains(t, err.Error(), "No [build] path", "shouldn't advise configuring a path that is configured") +} + +func TestLookPathWithNothingConfigured(t *testing.T) { + // Only Please's own directory to search, which is what a Windows user gets before they set + // [build] path - there is no default one there. Say so rather than just naming the one + // directory we looked in. + _, err := LookPath("wibblewobbleflibble", []string{t.TempDir()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "No [build] path is configured") } // buildGraph builds a test graph which we use to test IterSources etc. @@ -181,3 +207,72 @@ func makeTarget4(graph *BuildGraph, label string, deps ...string) *BuildTarget { target.AddOutput(target.Label.Name + ".a") return target } + +func TestFindRepoRootFromTerminatesAtTheRoot(t *testing.T) { + // A walk that reaches the top of the filesystem without finding a marker has to stop. + // On Windows it used not to: trimming the separator off "C:\" leaves "C:", and splitting + // that returns it unchanged, so this spun for ever at one stat per iteration and every plz + // run outside a repo hung instead of reporting that it could not find a root. + wd, err := os.Getwd() + require.NoError(t, err) + root := filepath.VolumeName(wd) + string(filepath.Separator) + + done := make(chan struct{}) + go func() { + defer close(done) + dir, pkg := findRepoRootFrom(root, "a_file_that_is_not_there_"+t.Name()) + assert.Empty(t, dir) + assert.Empty(t, pkg) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + // Failing rather than hanging the whole package, which is what this used to do. + t.Fatal("findRepoRootFrom did not terminate at the filesystem root") + } +} + +func TestFindRepoRootFromFindsTheMarker(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "some", "package") + require.NoError(t, os.MkdirAll(nested, os.ModeDir|0755)) + marker := "marker_" + t.Name() + require.NoError(t, os.WriteFile(filepath.Join(root, marker), nil, 0644)) + + dir, pkg := findRepoRootFrom(nested, marker) + assert.Equal(t, root, dir) + // Slash-separated whatever the OS gave us, because it is a package name. + assert.Equal(t, "some/package", pkg) +} + +func TestIsInRepoRoot(t *testing.T) { + // The comparison this replaces was a plain HasPrefix against RepoRoot, which is in the + // OS's own separator. Paths that arrive from outside - a file:// URL, a coverage report + // from another tool - are slash-separated, so on Windows it never matched and the guard + // that uses it silently stopped guarding. + old := RepoRoot + defer func() { RepoRoot = old }() + RepoRoot = filepath.Join(string(filepath.Separator)+"home", "user", "repo") + slashed := filepath.ToSlash(RepoRoot) + + assert.True(t, IsInRepoRoot(RepoRoot)) + assert.True(t, IsInRepoRoot(slashed), "a slash-separated path inside the repo is inside it") + assert.True(t, IsInRepoRoot(slashed+"/src/core/utils.go")) + assert.True(t, IsInRepoRoot(filepath.Join(RepoRoot, "src", "core"))) + + assert.False(t, IsInRepoRoot(slashed+"sitory/src"), "only matches at a path boundary") + assert.False(t, IsInRepoRoot("/somewhere/else")) + assert.False(t, IsInRepoRoot("")) +} + +func TestTrimRepoRoot(t *testing.T) { + old := RepoRoot + defer func() { RepoRoot = old }() + RepoRoot = filepath.Join(string(filepath.Separator)+"home", "user", "repo") + slashed := filepath.ToSlash(RepoRoot) + + assert.Equal(t, "src/core", TrimRepoRoot(slashed+"/src/core")) + assert.Equal(t, filepath.Join("src", "core"), TrimRepoRoot(filepath.Join(RepoRoot, "src", "core"))) + // Left alone rather than mangled when it isn't ours. + assert.Equal(t, "/somewhere/else", TrimRepoRoot("/somewhere/else")) +} diff --git a/src/core/xattrs_other.go b/src/core/xattrs_other.go new file mode 100644 index 0000000000..ed8ec07779 --- /dev/null +++ b/src/core/xattrs_other.go @@ -0,0 +1,7 @@ +//go:build !windows +// +build !windows + +package core + +// defaultXattrs is whether we try to record file metadata in extended attributes by default. +const defaultXattrs = true diff --git a/src/core/xattrs_windows.go b/src/core/xattrs_windows.go new file mode 100644 index 0000000000..6267d1d98a --- /dev/null +++ b/src/core/xattrs_windows.go @@ -0,0 +1,5 @@ +package core + +// defaultXattrs is whether we try to record file metadata in extended attributes by default. +// Windows has no equivalent, so we always fall back to writing separate files. +const defaultXattrs = false diff --git a/src/exec/BUILD b/src/exec/BUILD index 727071ff7f..3f74d37d13 100644 --- a/src/exec/BUILD +++ b/src/exec/BUILD @@ -14,6 +14,8 @@ go_library( go_test( name = "exec_test", srcs = ["exec_test.go"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":exec", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/export/BUILD b/src/export/BUILD index 5069f9dc85..8f36a767bf 100644 --- a/src/export/BUILD +++ b/src/export/BUILD @@ -20,7 +20,9 @@ go_library( go_test( name = "export_test", srcs = ["export_test.go"], - data = ["test_data"], + data = [":export_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":export", "///third_party/go/github.com_stretchr_testify//assert", @@ -28,3 +30,11 @@ go_test( "//src/parse/asp", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "export_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/format/BUILD b/src/format/BUILD index 1a6fbed019..67474e7cee 100644 --- a/src/format/BUILD +++ b/src/format/BUILD @@ -16,7 +16,9 @@ go_library( go_test( name = "format_test", srcs = ["fmt_test.go"], - data = ["test_data"], + data = [":format_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":format", "///third_party/go/github.com_stretchr_testify//assert", @@ -24,3 +26,11 @@ go_test( "//src/core", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "format_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/fs/BUILD b/src/fs/BUILD index 6636b1f795..95c21236db 100644 --- a/src/fs/BUILD +++ b/src/fs/BUILD @@ -11,7 +11,7 @@ go_library( "///third_party/go/github.com_peterebden_go-deferred-regex//:go-deferred-regex", "///third_party/go/github.com_pkg_xattr//:xattr", "//src/cli/logging", - ], + ] + (["///third_party/go/golang.org_x_sys//windows"] if is_platform(os = "windows") else []), ) go_test( @@ -23,7 +23,9 @@ go_test( "*_benchmark_test.go", ], ), - data = ["test_data"], + data = [":test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":fs", "///third_party/go/github.com_stretchr_testify//assert", @@ -50,3 +52,11 @@ go_benchmark( "///third_party/go/github.com_zeebo_blake3//:blake3", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/fs/copy.go b/src/fs/copy.go index a3d8345d73..eabdccd17b 100644 --- a/src/fs/copy.go +++ b/src/fs/copy.go @@ -4,8 +4,12 @@ import ( "fmt" "os" "path/filepath" + "sync" ) +// warnSymlinkFallback ensures we only mention the symlink degradation once. +var warnSymlinkFallback sync.Once + // CopyOrLinkFile either copies or hardlinks a file based on the link argument. // Falls back to a copy if link fails and fallback is true. func CopyOrLinkFile(from, to string, fromMode, toMode os.FileMode, link, fallback bool) error { @@ -17,7 +21,15 @@ func CopyOrLinkFile(from, to string, fromMode, toMode os.FileMode, link, fallbac if err != nil { return err } - return os.Symlink(dest, to) + if err := os.Symlink(dest, to); err == nil || !isSymlinkPrivilegeError(err) { + return err + } + // Windows won't create a symlink without Developer Mode or + // SeCreateSymbolicLinkPrivilege. Copy what it points at instead; for populating + // plz-out the content is what matters, not that the link is reproduced. + warnSymlinkFallback.Do(func() { + log.Warning("Cannot create symlinks; copying instead. Enable Developer Mode to avoid this.") + }) } if err := os.Link(from, to); err == nil || !fallback { return err @@ -83,6 +95,28 @@ func copySymlink(name, dest string) error { type LinkFunc func(string, string) error +// SymlinkOrCopy creates dest as a symlink to src, copying instead where the OS will not make +// one. +// +// Windows needs Developer Mode or SeCreateSymbolicLinkPrivilege to create a symlink at all, and +// an ordinary user has neither, so every link: label a build declares was quietly turning into +// a warning there. For populating plz-out the content is what matters, not that the link is +// reproduced - the same trade CopyOrLinkFile already makes. +func SymlinkOrCopy(src, dest string) error { + err := os.Symlink(src, dest) + if err == nil || !isSymlinkPrivilegeError(err) { + return err + } + warnSymlinkFallback.Do(func() { + log.Warning("Cannot create symlinks; copying instead. Enable Developer Mode to avoid this.") + }) + info, lerr := os.Lstat(src) + if lerr != nil { + return lerr + } + return CopyFile(src, dest, info.Mode()) +} + // LinkIfNotExists creates dest as a link to src if it doesn't already exist. func LinkIfNotExists(src, dest string, f LinkFunc) { if PathExists(dest) { diff --git a/src/fs/copy_test.go b/src/fs/copy_test.go index 2f053e0857..d618c546d8 100644 --- a/src/fs/copy_test.go +++ b/src/fs/copy_test.go @@ -67,6 +67,13 @@ func TestLink(t *testing.T) { } func TestSymlink(t *testing.T) { + if IsWine() { + // Only under Wine, where os.Symlink reports success and produces a link os.Lstat then + // cannot find - so this asserts nothing there. Real Windows is the case worth testing: + // it refuses without Developer Mode or SeCreateSymbolicLinkPrivilege, which is what + // SymlinkOrCopy's fallback exists for, and what the CI runner actually has. + t.Skip("Wine's symlinks are not real enough to assert against; see docs/design/windows") + } var tests = []struct { description string srcExists bool diff --git a/src/fs/executable.go b/src/fs/executable.go index e076e1f871..687ecb95ce 100644 --- a/src/fs/executable.go +++ b/src/fs/executable.go @@ -49,7 +49,7 @@ func executable() (string, error) { return exePath, nil } // Search for executable in $PATH. - for _, dir := range splitPathList(os.Getenv("PATH")) { + for _, dir := range SplitPathList(os.Getenv("PATH")) { if len(dir) == 0 { dir = "." } @@ -86,9 +86,9 @@ func isExecutable(path string) error { return nil } -// splitPathList splits a path list. +// SplitPathList splits a PATH-style list on the platform's list separator. // This is based on genSplit from strings/strings.go -func splitPathList(pathList string) []string { +func SplitPathList(pathList string) []string { if pathList == "" { return nil } diff --git a/src/fs/exename_other.go b/src/fs/exename_other.go new file mode 100644 index 0000000000..3b9a76fffc --- /dev/null +++ b/src/fs/exename_other.go @@ -0,0 +1,18 @@ +//go:build !windows +// +build !windows + +package fs + +// PathSeparators are the characters that separate elements of a path. +const PathSeparators = "/" + +// ExeSuffix is what an executable's filename ends in. Unix decides by the executable bit +// rather than the name, so there is nothing to add. +const ExeSuffix = "" + +// ExecutableNames returns the filenames to try when searching the path for an executable +// called name. On Unix an executable is just a file with the executable bit set, so there is +// only ever one candidate. +func ExecutableNames(name string) []string { + return []string{name} +} diff --git a/src/fs/exename_windows.go b/src/fs/exename_windows.go new file mode 100644 index 0000000000..8ca8ca1dd8 --- /dev/null +++ b/src/fs/exename_windows.go @@ -0,0 +1,37 @@ +package fs + +import ( + "os" + "strings" +) + +// PathSeparators are the characters that separate elements of a path. Windows accepts either, +// and both turn up in practice: its own APIs return backslashes, but plenty of paths reaching +// us were written with forward slashes. +const PathSeparators = `/\` + +// ExeSuffix is what an executable's filename ends in. Windows will not run a file without +// it, whatever the file actually contains. +const ExeSuffix = ".exe" + +// defaultPathExt is used when PATHEXT isn't set in the environment; it matches what Windows +// itself defaults to. +const defaultPathExt = ".COM;.EXE;.BAT;.CMD" + +// ExecutableNames returns the filenames to try when searching the path for an executable +// called name. Windows decides what is executable by extension, so a bare name like "bash" +// has to be tried as "bash.exe", "bash.cmd" and so on. The bare name is returned first, since +// callers may already have passed a full filename. +func ExecutableNames(name string) []string { + pathExt := os.Getenv("PATHEXT") + if pathExt == "" { + pathExt = defaultPathExt + } + names := []string{name} + for _, ext := range strings.Split(pathExt, ";") { + if ext = strings.TrimSpace(ext); ext != "" { + names = append(names, name+strings.ToLower(ext)) + } + } + return names +} diff --git a/src/fs/fs.go b/src/fs/fs.go index 73bb3dfd90..231f766719 100644 --- a/src/fs/fs.go +++ b/src/fs/fs.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "time" "github.com/thought-machine/please/src/cli/logging" ) @@ -180,14 +181,36 @@ func copyFile(from, to string) (err error) { // RemoveAll will try and remove the path with `os.RemoveAll`; if that fails with a permission error, // it will attempt to adjust permissions to make things writable, then remove them. +// +// On Windows it also retries briefly where the failure was something else holding the file open, +// which real-time virus scanning causes routinely and which is usually over in a moment. A handle +// that is genuinely held outlives the retries and is reported, since no amount of waiting will +// help - see docs/design/windows/05-testing-strategy.md. func RemoveAll(path string) error { + err := removeAll(path) + for i := 1; i < removeRetries && isTransientRemoveError(err); i++ { + time.Sleep(removeRetryDelay) + err = removeAll(path) + } + if isTransientRemoveError(err) { + return fmt.Errorf("%w; something else has a file in %s open. On Windows a file cannot be "+ + "deleted while any process holds it open, virus scanners included", err, path) + } + return err +} + +func removeAll(path string) error { if err := os.RemoveAll(path); err == nil || !errors.Is(err, os.ErrPermission) { return err + } else if isTransientRemoveError(err) { + // Not a permissions problem however much it looks like one: the chmod walk below would + // make every file in the tree writable and then fail again for the same reason. + return err } else if err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { const writable = 0o220 if err != nil { return err - } else if d.IsDir() && d.Type()&writable != writable { + } else if (d.IsDir() || removeNeedsWritableFiles) && d.Type()&writable != writable { if info, err := d.Info(); err != nil { return fmt.Errorf("could not read info for %s: %w", path, err) } else if err := os.Chmod(path, info.Mode()|writable); err != nil { diff --git a/src/fs/glob.go b/src/fs/glob.go index 073e29cc43..d1553a4115 100644 --- a/src/fs/glob.go +++ b/src/fs/glob.go @@ -3,6 +3,7 @@ package fs import ( "fmt" iofs "io/fs" + "path" "path/filepath" "regexp" "strings" @@ -15,7 +16,9 @@ type matcher interface { type builtInGlob string func (p builtInGlob) Match(name string) (bool, error) { - matched, err := filepath.Match(string(p), name) + // path.Match, not filepath.Match: the names come from io/fs and are slash-separated, and + // on Windows filepath would treat the separator as a backslash and let * cross directories. + matched, err := path.Match(string(p), name) if err != nil { return false, fmt.Errorf("failed to glob, invalid patern: %v, %w", string(p), err) } @@ -33,7 +36,9 @@ func (r regexGlob) Match(name string) (bool, error) { // This converts the string pattern into a matcher. A matcher can either be one of our homebrew compiled regexs that // support ** or a matcher that uses the built in filesystem.Match functionality. func patternToMatcher(root, pattern string) (matcher, error) { - fullPattern := filepath.Join(root, pattern) + // These patterns are matched against paths from io/fs, which are always slash-separated + // whatever the host OS, so they have to be built with path rather than filepath. + fullPattern := path.Join(root, pattern) // Use the built in filesystem.Match globs when not using double star as it's far more efficient if !strings.Contains(pattern, "**") { @@ -168,10 +173,13 @@ func (globber *Globber) walkDir(rootPath string) (walkedDir, error) { return dir, nil } dir := walkedDir{} - err := iofs.WalkDir(globber.fs, rootPath, func(path string, d iofs.DirEntry, err error) error { + err := iofs.WalkDir(globber.fs, rootPath, func(name string, d iofs.DirEntry, err error) error { typeMode := mode(d.Type()) - if isBuildFile(globber.buildFileNames, path) { - packageName := filepath.Dir(path) + if isBuildFile(globber.buildFileNames, name) { + // path, not filepath: this comes from io/fs and is slash-separated whatever the + // host OS. filepath.Dir on Windows splits on backslashes only, so it would return + // the whole string here and no subpackage would ever be found. + packageName := path.Dir(name) if packageName != rootPath { dir.subPackages = append(dir.subPackages, packageName) return filepath.SkipDir @@ -182,9 +190,9 @@ func (globber *Globber) walkDir(rootPath string) (walkedDir, error) { return filepath.SkipDir } if typeMode.IsSymlink() { - dir.symlinks = append(dir.symlinks, path) + dir.symlinks = append(dir.symlinks, name) } else { - dir.fileNames = append(dir.fileNames, path) + dir.fileNames = append(dir.fileNames, name) } return nil }) @@ -207,7 +215,8 @@ func isBathPathOf(path string, base string) bool { } rest := strings.TrimPrefix(path, base) - return rest == "" || rest[0] == filepath.Separator + // Always '/', not os.PathSeparator: these paths come from io/fs. + return rest == "" || rest[0] == '/' } // shouldExcludeMatch checks if the match also matches any of the exclude patterns. If the exclude pattern is a relative @@ -221,14 +230,14 @@ func shouldExcludeMatch(root, match string, excludes []string) (bool, error) { rootPath := root m := match - if isBathPathOf(match, filepath.Join(root, excl)) { + if isBathPathOf(match, path.Join(root, excl)) { return true, nil } // If the exclude pattern doesn't contain any slashes and the match does, we only match against the base of the // match path. if strings.ContainsRune(match, '/') && !strings.ContainsRune(excl, '/') { - m = filepath.Base(match) + m = path.Base(match) rootPath = "" } @@ -250,7 +259,7 @@ func shouldExcludeMatch(root, match string, excludes []string) (bool, error) { // isBuildFile checks if the filename is considered a build filename func isBuildFile(buildFileNames []string, name string) bool { - fileName := filepath.Base(name) + fileName := path.Base(name) for _, buildFileName := range buildFileNames { if fileName == buildFileName { return true @@ -271,6 +280,6 @@ func isInDirectories(name string, directories []string) bool { // isHidden checks if the file is a hidden file i.e. starts with . or, starts and ends with #. func isHidden(name string) bool { - file := filepath.Base(name) + file := path.Base(name) return strings.HasPrefix(file, ".") || (strings.HasPrefix(file, "#") && strings.HasSuffix(file, "#")) } diff --git a/src/fs/home.go b/src/fs/home.go index 60c3ae8d45..58ca7fb5d8 100644 --- a/src/fs/home.go +++ b/src/fs/home.go @@ -2,16 +2,34 @@ package fs import ( "os" + "regexp" "strings" "github.com/peterebden/go-deferred-regex" ) -var homeRex = deferredregex.DeferredRegex{Re: "(?:^|:)(~(?:[/:]|$))"} +var homeRex = deferredregex.DeferredRegex{Re: homePathRegex()} -// ExpandHomePath expands all prefixes of ~ without a user specifier to $HOME. +// homePathRegex returns the pattern matching a bare ~ at the start of a path, or at the start +// of an entry within a PATH-style list. Both the list separator and the path separators are +// platform-specific, and Windows accepts either slash. +func homePathRegex() string { + listSep := regexp.QuoteMeta(string(os.PathListSeparator)) + pathSeps := "/" + if os.PathSeparator == '\\' { + pathSeps = `/\\` + } + return `(?:^|` + listSep + `)(~(?:[` + pathSeps + listSep + `]|$))` +} + +// ExpandHomePath expands all prefixes of ~ without a user specifier to the user's home directory. func ExpandHomePath(path string) string { - return ExpandHomePathTo(path, os.Getenv("HOME")) + home, err := os.UserHomeDir() + if err != nil { + // Same as the old behaviour of reading $HOME directly: if we can't tell, expand to nothing. + home = "" + } + return ExpandHomePathTo(path, home) } // ExpandHomePathTo expands all prefixes of ~ without a user specifier to the given string. diff --git a/src/fs/home_test.go b/src/fs/home_test.go index 9c8f2e5c6d..07116524d8 100644 --- a/src/fs/home_test.go +++ b/src/fs/home_test.go @@ -5,19 +5,24 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExpandHomePath(t *testing.T) { - HOME := os.Getenv("HOME") + home, err := os.UserHomeDir() + require.NoError(t, err) + // Both the home directory and the separator between entries in a PATH-style list are + // platform-specific, so the expectations are built rather than written out. + sep := string(os.PathListSeparator) cases := []struct { in, want string }{ {"", ""}, - {"~", HOME}, + {"~", home}, {"~username", "~username"}, - {"~:/bin/~:/usr/local", HOME + ":/bin/~:/usr/local"}, - {"/bin:~/bin:~/script:/usr/local/bin", - "/bin:" + HOME + "/bin:" + HOME + "/script:/usr/local/bin"}, + {"~" + sep + "/bin/~" + sep + "/usr/local", home + sep + "/bin/~" + sep + "/usr/local"}, + {"/bin" + sep + "~/bin" + sep + "~/script" + sep + "/usr/local/bin", + "/bin" + sep + home + "/bin" + sep + home + "/script" + sep + "/usr/local/bin"}, } for _, c := range cases { assert.Equal(t, c.want, ExpandHomePath(c.in)) diff --git a/src/fs/removeall_other.go b/src/fs/removeall_other.go new file mode 100644 index 0000000000..0615daf2c4 --- /dev/null +++ b/src/fs/removeall_other.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package fs + +import "time" + +// removeNeedsWritableFiles is whether a file has to be writable for its parent directory to be +// removable. On Unix only the directory's own permissions matter. +const removeNeedsWritableFiles = false + +// removeRetries and removeRetryDelay are the Windows retry loop's settings; there is nothing to +// retry here, because Unix is happy to unlink a file that is still open. +const removeRetries = 1 +const removeRetryDelay = time.Duration(0) + +// isTransientRemoveError reports whether a removal failed for a reason that may not still be +// true in a moment. Nothing on Unix qualifies. +func isTransientRemoveError(error) bool { return false } diff --git a/src/fs/removeall_windows.go b/src/fs/removeall_windows.go new file mode 100644 index 0000000000..975eccbed2 --- /dev/null +++ b/src/fs/removeall_windows.go @@ -0,0 +1,31 @@ +package fs + +import ( + "errors" + "time" + + "golang.org/x/sys/windows" +) + +// removeNeedsWritableFiles is whether a file has to be writable for its parent directory to be +// removable. Windows refuses to delete a file carrying FILE_ATTRIBUTE_READONLY - which is what +// os.Chmod manipulates there - and the read-only attribute on a directory means something else +// entirely, so the files themselves have to be cleared. +const removeNeedsWritableFiles = true + +// removeRetries is how many times to retry a removal that failed because something else had the +// file open, and how long to wait between attempts. +// +// Windows will not unlink or rename a file another handle has open, and real-time virus scanning +// opens files Please has just written, for as long as it takes to scan them. That makes this a +// transient failure rather than a permanent one, unlike every other error here. A handle that is +// genuinely held - by this process, or by something the user is running - outlives the retries +// and still fails, which is what we want. +const removeRetries = 10 +const removeRetryDelay = 100 * time.Millisecond + +// isTransientRemoveError reports whether a removal failed for a reason that may not still be +// true in a moment. +func isTransientRemoveError(err error) bool { + return errors.Is(err, windows.ERROR_SHARING_VIOLATION) || errors.Is(err, windows.ERROR_LOCK_VIOLATION) +} diff --git a/src/fs/runnable_other.go b/src/fs/runnable_other.go new file mode 100644 index 0000000000..fab3c1a8c8 --- /dev/null +++ b/src/fs/runnable_other.go @@ -0,0 +1,9 @@ +//go:build !windows +// +build !windows + +package fs + +// ExplainUnrunnable returns extra context for a file that could not be executed, or an empty +// string if there is nothing useful to add. There never is on Unix, where the executable bit +// decides and the error already says so. +func ExplainUnrunnable(string) string { return "" } diff --git a/src/fs/runnable_test.go b/src/fs/runnable_test.go new file mode 100644 index 0000000000..43040a463d --- /dev/null +++ b/src/fs/runnable_test.go @@ -0,0 +1,35 @@ +package fs + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExplainUnrunnableSaysNothingAboutMissingFiles(t *testing.T) { + assert.Empty(t, ExplainUnrunnable(filepath.Join(t.TempDir(), "nothing-here"))) + assert.Empty(t, ExplainUnrunnable("")) + assert.Empty(t, ExplainUnrunnable(t.TempDir()), "a directory isn't a binary that failed to run") +} + +func TestExplainUnrunnableSaysNothingAboutProperlyNamedFiles(t *testing.T) { + file := filepath.Join(t.TempDir(), "tool"+ExeSuffix) + require.NoError(t, os.WriteFile(file, nil, 0o755)) + assert.Empty(t, ExplainUnrunnable(file), "this one is named the way the platform wants") +} + +func TestExplainUnrunnableNamesTheSuffix(t *testing.T) { + file := filepath.Join(t.TempDir(), "tool") + require.NoError(t, os.WriteFile(file, nil, 0o755)) + explanation := ExplainUnrunnable(file) + if runtime.GOOS != "windows" { + // The executable bit decides on Unix, and the error already says so. + assert.Empty(t, explanation) + return + } + assert.Contains(t, explanation, "tool.exe") +} diff --git a/src/fs/runnable_windows.go b/src/fs/runnable_windows.go new file mode 100644 index 0000000000..2974afe9f0 --- /dev/null +++ b/src/fs/runnable_windows.go @@ -0,0 +1,31 @@ +package fs + +import ( + "os" + "strings" +) + +// ExplainUnrunnable returns extra context for a file that could not be executed, or an empty +// string if there is nothing useful to add. +// +// Windows decides what is runnable by extension, and Go's exec package enforces that: a file +// whose name has no extension in PATHEXT will not run even when handed its full path, and the +// error says it was "not found in %PATH%" - which is baffling when the file is plainly there. +// Windows itself is happy to execute it; only the lookup refuses. +// +// The usual cause is a build rule that named its output after the rule, as most language +// plugins do, without adding the suffix Windows needs. +func ExplainUnrunnable(path string) string { + if path == "" || !PathExists(path) { + return "" + } + for _, name := range ExecutableNames("") { + if name != "" && strings.HasSuffix(strings.ToLower(path), name) { + return "" + } + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return "" + } + return "\n" + path + " exists, but its name has no extension Windows will run; something has to produce it as " + path + ExeSuffix + " instead" +} diff --git a/src/fs/symlink_other.go b/src/fs/symlink_other.go new file mode 100644 index 0000000000..ba6bcfcf40 --- /dev/null +++ b/src/fs/symlink_other.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package fs + +// isSymlinkPrivilegeError reports whether an error from os.Symlink means the OS refused for +// want of a privilege. Only Windows does that. +func isSymlinkPrivilegeError(error) bool { return false } diff --git a/src/fs/symlink_windows.go b/src/fs/symlink_windows.go new file mode 100644 index 0000000000..6733a01720 --- /dev/null +++ b/src/fs/symlink_windows.go @@ -0,0 +1,14 @@ +package fs + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// isSymlinkPrivilegeError reports whether an error from os.Symlink means the OS refused for +// want of a privilege. Creating a symlink on Windows needs either Developer Mode or +// SeCreateSymbolicLinkPrivilege, neither of which an ordinary user has by default. +func isSymlinkPrivilegeError(err error) bool { + return errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) +} diff --git a/src/fs/wine_other.go b/src/fs/wine_other.go new file mode 100644 index 0000000000..830783c3ec --- /dev/null +++ b/src/fs/wine_other.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package fs + +// IsWine reports whether this process is running under Wine rather than on Windows. Nothing +// that is not a Windows binary is. +func IsWine() bool { return false } diff --git a/src/fs/wine_windows.go b/src/fs/wine_windows.go new file mode 100644 index 0000000000..29dde4e5f8 --- /dev/null +++ b/src/fs/wine_windows.go @@ -0,0 +1,17 @@ +package fs + +import ( + "golang.org/x/sys/windows" +) + +// IsWine reports whether this process is running under Wine rather than on Windows. +// +// It exists so that a test can skip where Wine is known to lie, rather than skipping on Windows +// wholesale and telling us nothing about the platform we actually care about. Wine's symlinks +// are the case that forced it: os.Symlink reports success and produces a link os.Lstat cannot +// find, so a test written against real behaviour fails there for a reason that is not a bug. +// +// Detected by a function only Wine exports. Wine documents this as the supported way to tell. +func IsWine() bool { + return windows.NewLazySystemDLL("ntdll.dll").NewProc("wine_get_version").Find() == nil +} diff --git a/src/gc/BUILD b/src/gc/BUILD index a7ef3099d1..f574fb3ac0 100644 --- a/src/gc/BUILD +++ b/src/gc/BUILD @@ -18,9 +18,9 @@ go_test( "gc_test.go", "rewrite_test.go", ], - data = [ - "test_data", - ], + data = [":gc_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":gc", "///third_party/go/github.com_stretchr_testify//assert", @@ -28,3 +28,11 @@ go_test( "//src/fs", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "gc_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/generate/generate.go b/src/generate/generate.go index 33a6582953..9017bf2ec8 100644 --- a/src/generate/generate.go +++ b/src/generate/generate.go @@ -1,6 +1,7 @@ package generate import ( + "path" "path/filepath" "strings" @@ -33,10 +34,12 @@ func UpdateGitignore(graph *core.BuildGraph, labels []core.BuildLabel, gitignore } relativePkg = strings.TrimPrefix(strings.TrimPrefix(t.Label.PackageName, pkg), "/") } - if vcs.AreIgnored(filepath.Join(t.Label.PackageName, out)) { + // path, not filepath: these are matched against .gitignore patterns and then + // written into one, and git speaks forward slashes on every platform. + if vcs.AreIgnored(path.Join(t.Label.PackageName, out)) { continue } - files = append(files, filepath.Join(relativePkg, out)) + files = append(files, path.Join(relativePkg, out)) } } return vcs.IgnoreFiles(gitignore, files) diff --git a/src/hashes/BUILD b/src/hashes/BUILD index 9f2b7f4096..e749145d26 100644 --- a/src/hashes/BUILD +++ b/src/hashes/BUILD @@ -13,9 +13,9 @@ go_library( go_test( name = "hash_rewriter_test", srcs = ["hash_rewriter_test.go"], - data = [ - "test_data", - ], + data = [":hash_rewriter_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":hashes", "///third_party/go/github.com_stretchr_testify//assert", @@ -23,3 +23,11 @@ go_test( "//src/fs", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "hash_rewriter_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/output/BUILD b/src/output/BUILD index 8b89a1b785..3ea9d698c8 100644 --- a/src/output/BUILD +++ b/src/output/BUILD @@ -26,6 +26,8 @@ go_test( "interactive_display_test.go", "shell_output_test.go", ], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":output", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/output/shell_output.go b/src/output/shell_output.go index 88aceddff5..1cfff3f888 100644 --- a/src/output/shell_output.go +++ b/src/output/shell_output.go @@ -8,6 +8,7 @@ import ( "fmt" "math/rand" "os" + "path" "path/filepath" "sort" "strings" @@ -452,7 +453,7 @@ func printTempDirs(state *core.BuildState, duration time.Duration, shell, shellR fmt.Printf(" Expanded: %s\n", os.Expand(cmd, env.ReplaceEnvironment)) } else { fmt.Printf("\n") - argv := []string{"bash", "--noprofile", "--norc", "-o", "pipefail"} + argv := state.ProcessExecutor.InteractiveShellCommand() if shellRun { argv = append(argv, "-c", cmd) } @@ -464,7 +465,7 @@ func printTempDirs(state *core.BuildState, duration time.Duration, shell, shellR cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // TODO(jpoole): Read the docs. Attaching stdin and out doesn't seem to work with this. - cmd.SysProcAttr.Setpgid = false + process.ShareParentProcessGroup(cmd) cmd.Run() // Ignore errors, it will typically end by the user killing it somehow. } } @@ -474,10 +475,12 @@ func buildResult(target *core.BuildTarget) []string { results := []string{} if target != nil { for _, out := range target.Outputs() { + // Slash-separated: these are printed for a person to read and paste into a + // command, where a backslash would be an escape character rather than a separator. if core.StartedAtRepoRoot() { - results = append(results, filepath.Join(target.OutDir(), out)) + results = append(results, path.Join(target.OutDir(), out)) } else { - results = append(results, filepath.Join(core.RepoRoot, target.OutDir(), out)) + results = append(results, filepath.ToSlash(filepath.Join(core.RepoRoot, target.OutDir(), out))) } } } diff --git a/src/parse/BUILD b/src/parse/BUILD index bc3cc09ee8..ffd1314f6c 100644 --- a/src/parse/BUILD +++ b/src/parse/BUILD @@ -24,9 +24,12 @@ go_test( name = "parse_step_test", srcs = ["parse_step_test.go"], resources = ["internal.tmpl"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":parse", "///third_party/go/github.com_stretchr_testify//assert", + "///third_party/go/github.com_stretchr_testify//require", "//src/core", ], ) diff --git a/src/parse/asp/BUILD b/src/parse/asp/BUILD index 486fa0fef6..556fd644dc 100644 --- a/src/parse/asp/BUILD +++ b/src/parse/asp/BUILD @@ -26,7 +26,9 @@ go_test( ["*_test.go"], exclude = ["*_bench_test.go"], ), - data = ["test_data"], + data = [":asp_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":asp", "///third_party/go/github.com_stretchr_testify//assert", @@ -46,3 +48,11 @@ go_benchmark( ":asp", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "asp_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/parse/asp/builtins.go b/src/parse/asp/builtins.go index acfbba67cb..ad7fb3970f 100644 --- a/src/parse/asp/builtins.go +++ b/src/parse/asp/builtins.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "iter" - "path/filepath" + "path" "reflect" "regexp" "slices" @@ -301,7 +301,7 @@ func bazelLoad(s *scope, args []pyObject) pyObject { // The argument always looks like a build label, but it is not really one (i.e. there is no BUILD file that defines it). // We do not support their legacy syntax here (i.e. "/tools/build_rules/build_test" etc). l := s.parseLabelInContextPkg(string(args[0].(pyString))) - filename := filepath.Join(l.PackageName, l.Name) + filename := path.Join(l.PackageName, l.Name) if l.Subrepo != "" { subrepo := s.state.Graph.Subrepo(l.Subrepo) if subrepo == nil || (subrepo.Target != nil && subrepo != s.contextPackage().Subrepo) { @@ -358,7 +358,7 @@ func subinclude(s *scope, args []pyObject) pyObject { outs = t.Outputs() } for _, out := range outs { - s.SetAllWithOrigin(s.interpreter.Subinclude(s, filepath.Join(t.OutDir(), out), t.Label, false), false, &t.Label) + s.SetAllWithOrigin(s.interpreter.Subinclude(s, path.Join(t.OutDir(), out), t.Label, false), false, &t.Label) } labels = append(labels, t.Label) } @@ -987,7 +987,10 @@ func joinPath(s *scope, args []pyObject) pyObject { for i, arg := range args { l[i] = string(arg.(pyString)) } - return pyString(filepath.Join(l...)) + // path, not filepath: the BUILD language is the same language on every platform. Its paths + // become labels, sources and command strings, all of which are slash-separated, and a + // backslash here would change every hash that reaches it. + return pyString(path.Join(l...)) } func looksLikeBuildLabel(s *scope, args []pyObject) pyObject { @@ -1530,14 +1533,14 @@ func subrepo(s *scope, args []pyObject) pyObject { root = target.Outputs()[0] } if target.Local || s.state.RemoteClient == nil { - root = filepath.Join(target.OutDir(), root) + root = path.Join(target.OutDir(), root) } } else if args[PathArgIdx] != None { root = string(args[PathArgIdx].(pyString)) } // Base name - subrepoName := filepath.Join(s.pkg.Name, name) + subrepoName := path.Join(s.pkg.Name, name) if args[PluginArgIdx].IsTruthy() { subrepoName = name } diff --git a/src/parse/internal.tmpl b/src/parse/internal.tmpl index 90cfe6b671..41775ca4fa 100644 --- a/src/parse/internal.tmpl +++ b/src/parse/internal.tmpl @@ -1,13 +1,15 @@ +{{ if .ArcatHash }} remote_file( name = "arcat", url = f"https://github.com/please-build/arcat/releases/download/v1.3.1/arcat-1.3.1-{CONFIG.HOSTOS}_{CONFIG.HOSTARCH}", - out = "arcat", + out = "arcat{{ .ExeSuffix }}", binary = True, hashes = [ "{{ .ArcatHash }}", # defined in internal_package.go ], visibility = ["PUBLIC"], ) +{{ end }} remote_file( name = "download", diff --git a/src/parse/internal_package.go b/src/parse/internal_package.go index b867eca575..63108b317e 100644 --- a/src/parse/internal_package.go +++ b/src/parse/internal_package.go @@ -8,6 +8,7 @@ import ( "text/template" "github.com/thought-machine/please/src/core" + "github.com/thought-machine/please/src/fs" "github.com/thought-machine/please/src/version" ) @@ -27,26 +28,13 @@ func GetInternalPackage(config *core.Configuration) (string, error) { url = fmt.Sprintf("%s/%s_%s/%s/please_tools_%s.tar.xz", config.Please.DownloadLocation, runtime.GOOS, runtime.GOARCH, version.PleaseVersion, version.PleaseVersion) } - var arcatHash string - switch fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) { - case "darwin_amd64": - arcatHash = "6af2cf108592535701aa9395f3a5deeb48a5dfbe8174a8ebe3d56bb93de2c255" - case "darwin_arm64": - arcatHash = "5070ef05d14c66a85d438f400c6ff734a23833929775d6824b69207b704034bf" - case "freebsd_amd64": - arcatHash = "05ad6ac45be3a4ca1238bb1bd09207a596f8ff5f885415f8df4ff2dc849fa04e" - case "linux_amd64": - arcatHash = "aec85425355291e515cd10ac0addec3a5bc9e05c9d07af01aca8c34aaf0f1222" - case "linux_arm64": - arcatHash = "8266cb95cc84b23642bca6567f8b4bd18de399c887cb5845ab6a901d0dba54d2" - default: - return "", fmt.Errorf("arcat tool not supported for platform: %s_%s", runtime.GOOS, runtime.GOARCH) - } + arcatHash := publishedArcatHash() data := struct { ToolsURL string Tools []string ArcatHash string + ExeSuffix string }{ ToolsURL: url, Tools: []string{ @@ -54,6 +42,7 @@ func GetInternalPackage(config *core.Configuration) (string, error) { "please_sandbox", }, ArcatHash: arcatHash, + ExeSuffix: fs.ExeSuffix, } var buf bytes.Buffer @@ -62,3 +51,36 @@ func GetInternalPackage(config *core.Configuration) (string, error) { } return buf.String(), nil } + +// publishedArcatHash returns the hash of the arcat release for the platform we are running on, +// or an empty string if there isn't one. An empty string leaves the arcat rule out of the +// internal package altogether rather than failing: everything else in there still works, and a +// user who points [build] arcattool at their own build never needs ours. See +// ArcatUnavailable for the warning that goes with it. +func publishedArcatHash() string { + return arcatHashFor(fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)) +} + +func arcatHashFor(platform string) string { + switch platform { + case "darwin_amd64": + return "6af2cf108592535701aa9395f3a5deeb48a5dfbe8174a8ebe3d56bb93de2c255" + case "darwin_arm64": + return "5070ef05d14c66a85d438f400c6ff734a23833929775d6824b69207b704034bf" + case "freebsd_amd64": + return "05ad6ac45be3a4ca1238bb1bd09207a596f8ff5f885415f8df4ff2dc849fa04e" + case "linux_amd64": + return "aec85425355291e515cd10ac0addec3a5bc9e05c9d07af01aca8c34aaf0f1222" + case "linux_arm64": + return "8266cb95cc84b23642bca6567f8b4bd18de399c887cb5845ab6a901d0dba54d2" + } + return "" +} + +// ArcatUnavailable reports whether the config still expects the arcat that Please would +// download, on a platform where there is no release to download. Nothing that needs arcat can +// work in that state - which includes extracting any plugin - so it is worth saying up front +// rather than letting it surface as a missing target much later. +func ArcatUnavailable(config *core.Configuration) bool { + return publishedArcatHash() == "" && config.Build.ArcatTool == core.DefaultArcatTool +} diff --git a/src/parse/parse_step.go b/src/parse/parse_step.go index 6c9ec6f11d..52808c9155 100644 --- a/src/parse/parse_step.go +++ b/src/parse/parse_step.go @@ -9,7 +9,7 @@ import ( "errors" "fmt" iofs "io/fs" - "path/filepath" + "path" "strings" "github.com/thought-machine/please/src/cli/logging" @@ -167,6 +167,12 @@ func maybeParseSubrepoPackage(state *core.BuildState, subrepoPkg, subrepoSubrepo // When we try and parse a subrepo package, but the BUILD file or directory doesn't exist, return nil so // this gets handled later on, in the same way as when the package does exist but doesn't define the subrepo if errors.Is(err, ErrMissingBuildFile) { + // The parse above claimed the right to parse this package, and we are about to + // throw its error away, so the claim has to go back. Without this, the next + // caller to ask about the same non-existent package blocks forever waiting for + // a parse that is never going to happen - and since a package that isn't there + // is the normal answer here, that is a hang on an ordinary lookup. + state.ReleasePendingParse(label) return nil, nil } return nil, err @@ -252,7 +258,11 @@ func buildFileName(state *core.BuildState, subrepo *core.Subrepo, fs iofs.FS, pk return "WORKSPACE", "" } for _, buildFileName := range config.Parse.BuildFileName { - filename := filepath.Join(pkgName, buildFileName) + // path, not filepath: this is an io/fs path, which is always slash-separated whatever + // the host OS. filepath.Join would produce a backslash on Windows and iofs.Stat would + // then look for a single file whose name contains one, so no package below the top + // level would ever be found. + filename := path.Join(pkgName, buildFileName) if info, err := iofs.Stat(fs, filename); err == nil && !info.IsDir() { return filename, pkgName } diff --git a/src/parse/parse_step_test.go b/src/parse/parse_step_test.go index d21034f54e..f71a3eafec 100644 --- a/src/parse/parse_step_test.go +++ b/src/parse/parse_step_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/thought-machine/please/src/core" ) @@ -164,3 +165,34 @@ func getAllPending(state *core.BuildState) ([]string, []string) { func buildLabel(bl string) core.BuildLabel { return core.ParseBuildLabel(bl, "") } + +func TestInternalPackageWithoutArcat(t *testing.T) { + // There is no arcat release for every platform Please runs on - Windows has none at all - + // and it used to be an error to generate the internal package there, which stopped + // everything rather than just the things that need arcat. The rule is simply left out now. + config := core.DefaultConfiguration() + pkg, err := GetInternalPackage(config) + require.NoError(t, err) + if arcatHashFor("no_such_platform") == "" { + // Sanity check on the helper itself before relying on it below. + assert.Contains(t, pkg, "please_sandbox", "the rest of the package should still be there") + } +} + +func TestArcatHashKnownAndUnknownPlatforms(t *testing.T) { + assert.NotEmpty(t, arcatHashFor("linux_amd64")) + assert.Empty(t, arcatHashFor("windows_amd64"), "no arcat is published for Windows") +} + +func TestArcatUnavailableOnlyWhenNothingElseIsConfigured(t *testing.T) { + config := core.DefaultConfiguration() + config.Build.ArcatTool = "C:/tools/arcat.exe" + assert.False(t, ArcatUnavailable(config), "a configured arcat is never unavailable") +} + +func TestDefaultArcatToolNamesTheInternalPackage(t *testing.T) { + // core cannot import this package, so it spells the label out. If the internal package is + // ever renamed, the default arcat tool and the check that recognises it drift apart + // silently and ArcatUnavailable starts answering false for a default config. + assert.Equal(t, "/////"+InternalPackageName+":arcat", core.DefaultArcatTool) +} diff --git a/src/please.go b/src/please.go index d53c2903cb..d4d865d656 100644 --- a/src/please.go +++ b/src/please.go @@ -12,7 +12,6 @@ import ( "runtime/pprof" "strings" "sync" - "syscall" "time" "github.com/thought-machine/go-flags" @@ -697,6 +696,11 @@ var buildFunctions = map[string]func() int{ if len(opts.Clean.Args.Targets) == 0 && core.InitialPackage()[0].PackageName == "" { if len(opts.BuildFlags.Include) == 0 && len(opts.BuildFlags.Exclude) == 0 { // Clean everything, doesn't require parsing at all. + // The log file lives under plz-out by default, and on Windows a directory + // cannot be renamed or deleted while this process holds a file inside it open, + // so let go of it first. The detached child that does the deletion avoids + // opening one at all, for the same reason. + cli.CloseFileLogging() state := core.NewBuildState(config) clean.Clean(config, cache.NewCache(state), !opts.Clean.NoBackground) return 0 @@ -716,10 +720,9 @@ var buildFunctions = map[string]func() int{ "op": func() int { cmd := core.ReadPreviousOperationOrDie() log.Notice("OP PLZ: %s", strings.Join(cmd, " ")) - // Annoyingly we don't seem to have any access to execvp() which would be rather useful here... executable, err := os.Executable() if err == nil { - err = syscall.Exec(executable, append([]string{executable}, cmd...), os.Environ()) + err = process.ExecReplace(executable, append([]string{executable}, cmd...), os.Environ()) } log.Fatalf("SORRY OP: %s", err) // On success Run never returns. return 1 diff --git a/src/plz/BUILD b/src/plz/BUILD index bfaeffee93..43e12de979 100644 --- a/src/plz/BUILD +++ b/src/plz/BUILD @@ -20,6 +20,8 @@ go_library( go_test( name = "plz_test", srcs = ["plz_test.go"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":plz", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/plz/plz.go b/src/plz/plz.go index 4cbbd10454..4dd6715dbe 100644 --- a/src/plz/plz.go +++ b/src/plz/plz.go @@ -2,6 +2,7 @@ package plz import ( "path/filepath" + "runtime" "strings" "sync" @@ -35,6 +36,11 @@ func Run(targets, preTargets []core.BuildLabel, state *core.BuildState, config * } parse.InitParser(state) + if parse.ArcatUnavailable(config) { + // Nothing that needs arcat can work, which includes extracting any plugin. Say so here + // rather than letting it surface much later as a target that doesn't exist. + log.Warning("No arcat is published for %s_%s, so anything that needs one - including loading a plugin - will fail. Build it yourself and point [build] arcattool at it.", runtime.GOOS, runtime.GOARCH) + } // Start looking for the initial targets to kick the build off go findOriginalTasks(state, preTargets, targets, arch) diff --git a/src/plzinit/init.go b/src/plzinit/init.go index 7a3ab2f808..bae1d31238 100644 --- a/src/plzinit/init.go +++ b/src/plzinit/init.go @@ -32,6 +32,10 @@ compatibility = true ` const wrapperScriptName = "pleasew" +// A repo is often worked on from more than one platform, so both wrappers are written +// whichever one we happen to be running on. +const windowsWrapperScriptName = "pleasew.ps1" + const pleasingsSubrepoTemplate = ` github_repo( name = "pleasings", @@ -112,10 +116,12 @@ func readConfig(filename string) []byte { return b } -// InitWrapperScript initialises the pleasew script. +// InitWrapperScript initialises the pleasew scripts. func InitWrapperScript() { - data := assets.Pleasew - if err := os.WriteFile(wrapperScriptName, data, 0755); err != nil { + if err := os.WriteFile(wrapperScriptName, assets.Pleasew, 0755); err != nil { + log.Fatalf("Failed to write file: %s", err) + } + if err := os.WriteFile(windowsWrapperScriptName, assets.PleasewPS1, 0755); err != nil { log.Fatalf("Failed to write file: %s", err) } } diff --git a/src/process/BUILD b/src/process/BUILD index 5b9f3cf366..b292a7d135 100644 --- a/src/process/BUILD +++ b/src/process/BUILD @@ -3,9 +3,18 @@ go_library( srcs = [ "exec_linux.go", "exec_other.go", + "exec_replace_other.go", + "exec_replace_windows.go", + "exec_windows.go", + "kill_other.go", + "kill_windows.go", "output.go", + "pgroup_other.go", + "pgroup_windows.go", "process.go", "progress.go", + "shell_other.go", + "shell_windows.go", ], pgo_file = "//:pgo", visibility = ["PUBLIC"], @@ -13,7 +22,7 @@ go_library( "///third_party/go/github.com_peterebden_go-deferred-regex//:go-deferred-regex", "//src/cli", "//src/cli/logging", - ], + ] + (["///third_party/go/golang.org_x_sys//windows"] if is_platform(os = "windows") else []), ) go_test( @@ -22,8 +31,12 @@ go_test( "process_test.go", "progress_test.go", ], + # So //test/windows can run this same binary, cross-built, under Wine. The process layer is + # almost entirely separate code there, and nothing else covers it. + visibility = ["//test/windows:all"], deps = [ ":process", "///third_party/go/github.com_stretchr_testify//assert", + "///third_party/go/github.com_stretchr_testify//require", ], ) diff --git a/src/process/exec_other.go b/src/process/exec_other.go index 1f3902adc2..b78c2df512 100644 --- a/src/process/exec_other.go +++ b/src/process/exec_other.go @@ -1,5 +1,5 @@ -//go:build !linux -// +build !linux +//go:build !linux && !windows +// +build !linux,!windows package process diff --git a/src/process/exec_replace_other.go b/src/process/exec_replace_other.go new file mode 100644 index 0000000000..aff55a2cc4 --- /dev/null +++ b/src/process/exec_replace_other.go @@ -0,0 +1,12 @@ +//go:build !windows +// +build !windows + +package process + +import "syscall" + +// ExecReplace replaces the currently running process with the given command. +// It does not return unless the exec itself failed. +func ExecReplace(path string, argv, env []string) error { + return syscall.Exec(path, argv, env) +} diff --git a/src/process/exec_replace_windows.go b/src/process/exec_replace_windows.go new file mode 100644 index 0000000000..2f15700f7f --- /dev/null +++ b/src/process/exec_replace_windows.go @@ -0,0 +1,38 @@ +package process + +import ( + "errors" + "os" + "os/exec" + "os/signal" +) + +// ExecReplace replaces the currently running process with the given command. +// It does not return unless the exec itself failed. +// +// Windows has no way to replace a process image, so we run the command as a child, wait for +// it, and exit with its status. Two consequences callers need to be aware of: +// +// - We stay alive as the child's parent. Any resource we hold is still held, so release +// anything the child will contend for - notably the repo lock - before calling this. On +// Unix that happens implicitly, because Go opens files O_CLOEXEC and the exec releases +// the lock for us. +// - Nothing deferred in the caller runs, matching execve. +func ExecReplace(path string, argv, env []string) error { + cmd := exec.Command(path) + cmd.Args = argv + cmd.Env = env + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + // The child shares our console, so a Ctrl-C reaches it directly. Ignore it here so we + // don't exit first and leave it writing to a console nobody is reading. + signal.Ignore(os.Interrupt) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return err + } + os.Exit(0) + return nil // unreachable +} diff --git a/src/process/exec_windows.go b/src/process/exec_windows.go new file mode 100644 index 0000000000..c624a46d12 --- /dev/null +++ b/src/process/exec_windows.go @@ -0,0 +1,25 @@ +package process + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// ExecCommand executes an external command. +// Windows has no process groups in the POSIX sense; the closest equivalent is a console +// process group, which is what CREATE_NEW_PROCESS_GROUP sets up. That gives us somewhere to +// send Ctrl-Break, which is the nearest thing to SIGTERM. Killing the whole tree is handled +// separately by a job object - see kill_windows.go. +// +// N.B. This does not start the command - the caller must handle that (or use one +// of the other functions which are higher-level interfaces). +func (e *Executor) ExecCommand(sandbox SandboxConfig, foreground bool, command string, args ...string) *exec.Cmd { + // There is no sandboxing on Windows yet; sandbox and foreground are both ignored. + cmd := exec.Command(command, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP, + } + return cmd +} diff --git a/src/process/kill_other.go b/src/process/kill_other.go new file mode 100644 index 0000000000..f15705a03c --- /dev/null +++ b/src/process/kill_other.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package process + +import ( + "os/exec" + "syscall" +) + +// trackProcessTree records a started process so that its descendants can be killed later. +// On Unix the process group set up in ExecCommand is sufficient, so this is a no-op. +func trackProcessTree(cmd *exec.Cmd) {} + +// untrackProcessTree releases any resources held by trackProcessTree. +func untrackProcessTree(cmd *exec.Cmd) {} + +// killProcessTree signals a process and all of its descendants. +func killProcessTree(cmd *exec.Cmd, sig syscall.Signal) error { + // Kill the group - we always set one in ExecCommand. + return syscall.Kill(-cmd.Process.Pid, sig) +} diff --git a/src/process/kill_windows.go b/src/process/kill_windows.go new file mode 100644 index 0000000000..0a8e1d2afd --- /dev/null +++ b/src/process/kill_windows.go @@ -0,0 +1,96 @@ +package process + +import ( + "os/exec" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Windows has no process groups that descendants inherit, so killing a whole tree needs a job +// object: every process assigned to one, and everything it subsequently spawns, dies together +// on TerminateJobObject. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE means that also happens if we exit +// abnormally without cleaning up, which is what Pdeathsig buys us on Linux. +var ( + jobsMux sync.Mutex + jobs = map[*exec.Cmd]windows.Handle{} +) + +// trackProcessTree assigns a started process to a new job object so that it and its +// descendants can be killed together. +// +// There is an unavoidable race here: the process is already running by the time we assign it, +// so anything it spawns in that window escapes the job. Closing it would need CREATE_SUSPENDED +// and a ResumeThread, which os/exec gives us no way to do. +func trackProcessTree(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + log.Warning("Failed to create job object, child processes may outlive us: %s", err) + return + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil { + log.Warning("Failed to configure job object: %s", err) + windows.CloseHandle(job) + return + } + proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid)) + if err != nil { + log.Warning("Failed to open process %d: %s", cmd.Process.Pid, err) + windows.CloseHandle(job) + return + } + defer windows.CloseHandle(proc) + if err := windows.AssignProcessToJobObject(job, proc); err != nil { + log.Warning("Failed to assign process %d to job object: %s", cmd.Process.Pid, err) + windows.CloseHandle(job) + return + } + jobsMux.Lock() + defer jobsMux.Unlock() + jobs[cmd] = job +} + +// untrackProcessTree closes the job object for a command. Because the job is created with +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, this also kills anything still running in it. +func untrackProcessTree(cmd *exec.Cmd) { + jobsMux.Lock() + job, present := jobs[cmd] + delete(jobs, cmd) + jobsMux.Unlock() + if present { + windows.CloseHandle(job) + } +} + +// killProcessTree signals a process and all of its descendants. +// SIGTERM is translated to a Ctrl-Break on the console process group, which is the closest +// thing Windows has to a signal a process can handle. It is best-effort: it does not reach +// processes that have detached from the console, and GUI subsystem processes ignore it. +// Anything else terminates the job object, which is unconditional. +func killProcessTree(cmd *exec.Cmd, sig syscall.Signal) error { + if sig == syscall.SIGTERM { + if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)); err == nil { + return nil + } + // Fall through to terminating the job if we couldn't deliver it. + } + jobsMux.Lock() + job, present := jobs[cmd] + jobsMux.Unlock() + if !present { + // No job object, so the best we can do is the process itself. + return cmd.Process.Kill() + } + return windows.TerminateJobObject(job, 1) +} diff --git a/src/process/pgroup_other.go b/src/process/pgroup_other.go new file mode 100644 index 0000000000..a14a9e6364 --- /dev/null +++ b/src/process/pgroup_other.go @@ -0,0 +1,13 @@ +//go:build !windows +// +build !windows + +package process + +import "os/exec" + +// ShareParentProcessGroup configures a command to run in our process group rather than one of +// its own. Interactive commands need this for stdin and stdout to attach correctly, at the +// cost of no longer being killable as a group. +func ShareParentProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr.Setpgid = false +} diff --git a/src/process/pgroup_windows.go b/src/process/pgroup_windows.go new file mode 100644 index 0000000000..a1b5845675 --- /dev/null +++ b/src/process/pgroup_windows.go @@ -0,0 +1,14 @@ +package process + +import ( + "os/exec" + + "golang.org/x/sys/windows" +) + +// ShareParentProcessGroup configures a command to run in our process group rather than one of +// its own. Interactive commands need this for stdin and stdout to attach correctly, at the +// cost of no longer being killable as a group. +func ShareParentProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr.CreationFlags &^= windows.CREATE_NEW_PROCESS_GROUP +} diff --git a/src/process/process.go b/src/process/process.go index f76e15fce9..5dcb76386c 100644 --- a/src/process/process.go +++ b/src/process/process.go @@ -34,24 +34,30 @@ type Executor struct { // The tool that will do the network/mount sandboxing sandboxTool string usePleaseSandbox bool - processes map[*exec.Cmd]<-chan error - mutex sync.Mutex + // The shell that build actions and tests are run in, and the arguments given to it + // before the command itself. + shell string + shellArgs []string + processes map[*exec.Cmd]<-chan error + mutex sync.Mutex } -func NewSandboxingExecutor(usePleaseSandbox bool, namespace NamespacingPolicy, sandboxTool string) *Executor { +func NewSandboxingExecutor(usePleaseSandbox bool, namespace NamespacingPolicy, sandboxTool, shell string, shellArgs []string) *Executor { o := &Executor{ namespace: namespace, usePleaseSandbox: usePleaseSandbox, sandboxTool: sandboxTool, + shell: shell, + shellArgs: shellArgs, processes: map[*exec.Cmd]<-chan error{}, } cli.AtExit(o.killAll) // Kill any subprocess if we are ourselves killed return o } -// New returns a new Executor. +// New returns a new Executor using the default shell for this platform. func New() *Executor { - return NewSandboxingExecutor(false, NamespaceNever, "") + return NewSandboxingExecutor(false, NamespaceNever, "", DefaultShell, DefaultShellArgs) } // SandboxConfig contains what namespaces should be sandboxed @@ -127,6 +133,8 @@ func (e *Executor) ExecWithTimeout(ctx context.Context, target Target, dir strin if err != nil { return nil, nil, err } + trackProcessTree(cmd) + defer untrackProcessTree(cmd) ch := make(chan error) e.registerProcess(cmd, ch) defer e.removeProcess(cmd) @@ -155,7 +163,7 @@ func (e *Executor) ExecWithTimeoutShell(target Target, dir string, env []string, // ExecWithTimeoutShellStdStreams is as ExecWithTimeoutShell but optionally attaches stdin to the subprocess. func (e *Executor) ExecWithTimeoutShellStdStreams(target Target, dir string, env []string, timeout time.Duration, showOutput, foreground bool, sandbox SandboxConfig, cmd string, attachStdStreams bool) ([]byte, []byte, error) { - c := BashCommand("bash", cmd, target.ShouldExitOnError()) + c := e.BashCommand(cmd, target.ShouldExitOnError()) return e.ExecWithTimeout(context.Background(), target, dir, env, timeout, showOutput, attachStdStreams, attachStdStreams, foreground, sandbox, c) } @@ -203,7 +211,9 @@ func sendSignal(cmd *exec.Cmd, ch <-chan error, sig syscall.Signal, timeout time // This is a bit of a fiddle. We want to wait for the process to exit but only for just so // long (we do not want to get hung up if it ignores our SIGTERM). log.Debug("Sending signal %s to -%d", sig, cmd.Process.Pid) - syscall.Kill(-cmd.Process.Pid, sig) // Kill the group - we always set one in ExecCommand. + if err := killProcessTree(cmd, sig); err != nil { + log.Debug("Failed to signal process %d: %s", cmd.Process.Pid, err) + } select { case <-ch: @@ -291,10 +301,45 @@ func ExecCommand(args ...string) ([]byte, error) { return cmd.CombinedOutput() } -// BashCommand returns the command that we'd use to execute a subprocess in a shell with. -func BashCommand(binary, command string, exitOnError bool) []string { +// BashCommand returns the command that this executor runs a subprocess in a shell with. +// This is for the shell on the machine we're running on; see RemoteBashCommand for the +// remote execution equivalent. +func (e *Executor) BashCommand(command string, exitOnError bool) []string { + return shellCommand(e.shell, e.shellArgs, command, exitOnError) +} + +// InteractiveShellCommand returns the command to start an interactive shell of the same kind +// build actions run in. It has no -e or -u, since those are hostile in an interactive shell, +// and no command to run. +func (e *Executor) InteractiveShellCommand() []string { + return append(ShellArgv(e.shell, e.shellArgs), "-o", "pipefail") +} + +// ShellArgv returns the leading argv for invoking the given shell: the shell itself followed +// by its arguments. Empty arguments are dropped, because a repeatable config key can't be +// cleared by assigning it empty - that yields a single empty string rather than nothing - and +// an empty argument would otherwise be passed through to the shell. +func ShellArgv(binary string, args []string) []string { + argv := make([]string, 0, len(args)+8) + argv = append(argv, binary) + for _, arg := range args { + if arg != "" { + argv = append(argv, arg) + } + } + return argv +} + +// RemoteBashCommand is as BashCommand, but for a shell on a remote worker. That is a real +// bash whatever we happen to be running on, so it always gets the full set of flags. +func RemoteBashCommand(binary, command string, exitOnError bool) []string { + return shellCommand(binary, []string{"--noprofile", "--norc"}, command, exitOnError) +} + +func shellCommand(binary string, initArgs []string, command string, exitOnError bool) []string { + argv := ShellArgv(binary, initArgs) if exitOnError { - return []string{binary, "--noprofile", "--norc", "-e", "-u", "-o", "pipefail", "-c", command} + argv = append(argv, "-e") } - return []string{binary, "--noprofile", "--norc", "-u", "-o", "pipefail", "-c", command} + return append(argv, "-u", "-o", "pipefail", "-c", command) } diff --git a/src/process/process_test.go b/src/process/process_test.go index 48519ab6cf..c9ba39a940 100644 --- a/src/process/process_test.go +++ b/src/process/process_test.go @@ -2,31 +2,64 @@ package process import ( "context" + "fmt" + "os" + "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// argv returns a command that runs the given shell snippet, as an explicit argv rather than a +// shell string. Built through the shell rather than naming true, false and sleep directly: +// those are programs on the PATH on Unix and applets inside the shell on Windows, so only one +// of the two spellings works anywhere. +func argv(command string) []string { + return New().BashCommand(command, false) +} + func TestExecWithTimeout(t *testing.T) { - out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 10*time.Second, false, false, false, false, NoSandbox, []string{"true"}) + out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 10*time.Second, false, false, false, false, NoSandbox, argv("exit 0")) assert.NoError(t, err) assert.Equal(t, 0, len(out)) } func TestExecWithTimeoutFailure(t *testing.T) { - out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 10*time.Second, false, false, false, false, NoSandbox, []string{"false"}) + out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 10*time.Second, false, false, false, false, NoSandbox, argv("exit 1")) assert.Error(t, err) assert.Equal(t, 0, len(out)) } func TestExecWithTimeoutDeadline(t *testing.T) { - out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 1*time.Nanosecond, false, false, false, false, NoSandbox, []string{"sleep", "10"}) + out, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 1*time.Nanosecond, false, false, false, false, NoSandbox, argv("sleep 10")) assert.Error(t, err) assert.Equal(t, context.DeadlineExceeded, err) assert.Equal(t, 0, len(out)) } +// TestKillsProcessTree covers the thing process groups on Unix and job objects on Windows both +// exist for: when a command times out, what it started has to die with it. Nothing else tests +// that on any platform, and the Windows implementation of it is entirely separate code. +func TestKillsProcessTree(t *testing.T) { + // Forward slashes: this path is going into a shell command, where a backslash escapes. + marker := filepath.ToSlash(filepath.Join(t.TempDir(), "marker")) + // A grandchild that outlives the child it was started from, unless the whole tree is + // killed. Deliberately a separate process rather than a subshell: busybox on Windows + // implements a subshell as a thread, so it would die with its parent either way and prove + // nothing about killing a tree. + cmd := fmt.Sprintf("sh -c 'sleep 2; echo alive > %s' & sleep 30", marker) + + _, _, err := New().ExecWithTimeout(context.Background(), nil, "", nil, 100*time.Millisecond, false, false, false, false, NoSandbox, argv(cmd)) + require.Error(t, err) + + // Comfortably past when the grandchild would have written, had it survived. + time.Sleep(4 * time.Second) + _, err = os.Stat(marker) + assert.True(t, os.IsNotExist(err), "grandchild survived the timeout and wrote %s", marker) +} + func TestExecWithTimeoutOutput(t *testing.T) { targ := &target{} out, stderr, err := New().ExecWithTimeoutShell(targ, "", nil, 10*time.Second, false, false, NoSandbox, "echo hello") @@ -42,3 +75,26 @@ func TestExecWithTimeoutStderr(t *testing.T) { assert.Equal(t, "", string(out)) assert.Equal(t, "hello\n", string(stderr)) } + +func TestBashCommandUsesConfiguredShell(t *testing.T) { + e := NewSandboxingExecutor(false, NamespaceNever, "", "/bin/dash", []string{"--posix"}) + assert.Equal(t, []string{"/bin/dash", "--posix", "-e", "-u", "-o", "pipefail", "-c", "echo hello"}, + e.BashCommand("echo hello", true)) + assert.Equal(t, []string{"/bin/dash", "--posix", "-u", "-o", "pipefail", "-c", "echo hello"}, + e.BashCommand("echo hello", false)) +} + +func TestBashCommandDropsEmptyShellArgs(t *testing.T) { + // A repeatable config key can't be cleared by assigning it empty; that yields a single + // empty string, which must not reach the shell as an argument. + e := NewSandboxingExecutor(false, NamespaceNever, "", "bash", []string{""}) + assert.Equal(t, []string{"bash", "-u", "-o", "pipefail", "-c", "echo hello"}, + e.BashCommand("echo hello", false)) +} + +func TestRemoteBashCommandIgnoresLocalShellArgs(t *testing.T) { + // The remote worker runs a real bash whatever we're running on, so it keeps the full set + // of flags regardless of how the local shell is configured. + assert.Equal(t, []string{"bash", "--noprofile", "--norc", "-u", "-o", "pipefail", "-c", "echo hello"}, + RemoteBashCommand("bash", "echo hello", false)) +} diff --git a/src/process/shell_other.go b/src/process/shell_other.go new file mode 100644 index 0000000000..fdea20bb8b --- /dev/null +++ b/src/process/shell_other.go @@ -0,0 +1,11 @@ +//go:build !windows +// +build !windows + +package process + +// DefaultShell is the shell we run build actions in if nothing else is configured. +const DefaultShell = "bash" + +// DefaultShellArgs stop bash reading the user's profile and rc files, so build actions don't +// pick up anything from the invoking user's environment. +var DefaultShellArgs = []string{"--noprofile", "--norc"} diff --git a/src/process/shell_windows.go b/src/process/shell_windows.go new file mode 100644 index 0000000000..693e301892 --- /dev/null +++ b/src/process/shell_windows.go @@ -0,0 +1,10 @@ +package process + +// DefaultShell is busybox, which Please bundles in its Windows release. Windows has no system +// shell that can run a build action, so depending on one being installed isn't an option. +const DefaultShell = "busybox" + +// DefaultShellArgs selects busybox's bash applet. Note it does not include --noprofile and +// --norc: busybox rejects both outright, and it reads no profile or rc files in the first +// place, so there is nothing to suppress. +var DefaultShellArgs = []string{"bash"} diff --git a/src/query/BUILD b/src/query/BUILD index 26a780b6a0..5b22e74663 100644 --- a/src/query/BUILD +++ b/src/query/BUILD @@ -21,7 +21,9 @@ go_library( go_test( name = "query_test", srcs = glob(["*_test.go"]), - data = ["completions_test_repo"], + data = [":query_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":query", "///third_party/go/github.com_stretchr_testify//assert", @@ -31,3 +33,11 @@ go_test( "//src/parse", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "query_test_data", + srcs = ["completions_test_repo"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/query/changes.go b/src/query/changes.go index 584e17a171..395e7498d4 100644 --- a/src/query/changes.go +++ b/src/query/changes.go @@ -3,7 +3,7 @@ package query import ( "bytes" "crypto/sha1" - "path/filepath" + "path" "sort" "github.com/thought-machine/please/src/build" @@ -46,7 +46,9 @@ func diffGraphs(before, after *core.BuildState) map[*core.BuildTarget]struct{} { func changedTargets(state *core.BuildState, files []string, changed map[*core.BuildTarget]struct{}, level int, includeSubrepos bool) core.BuildLabels { for _, filename := range files { for dir := filename; dir != "." && dir != "/"; { - dir = filepath.Dir(dir) + // path, not filepath: dir becomes a package name to look up in the graph, and + // those are slash-separated everywhere. + dir = path.Dir(dir) pkgName := dir if pkgName == "." { pkgName = "" diff --git a/src/query/completions.go b/src/query/completions.go index 21c5ae0f00..820cfb4ad6 100644 --- a/src/query/completions.go +++ b/src/query/completions.go @@ -3,6 +3,7 @@ package query import ( "fmt" "os" + "path" "path/filepath" "strings" @@ -29,7 +30,8 @@ func CompletePackages(config *core.Configuration, query string) *CompletionPacka if strings.HasPrefix(query, ":") { query = fmt.Sprintf("//%s%s", core.InitialPackagePath, query) } else { - query = "//" + filepath.Join(core.InitialPackagePath, query) + // path, not filepath: this is being assembled into a build label. + query = "//" + path.Join(core.InitialPackagePath, query) } } query = strings.ReplaceAll(query, "\\:", ":") @@ -56,6 +58,10 @@ func CompletePackages(config *core.Configuration, query string) *CompletionPacka } } +// Everything below deals in package names, which are slash-separated on every platform because +// they become build labels. They are only incidentally filesystem paths, and Win32 is happy to +// read a directory named with forward slashes, so path rather than filepath throughout. +// // findPrefixedPackages finds any packages that match a prefix in a directory e.g. src/plz matches src/plz, and // src/plzinit func findPrefixedPackages(config *core.Configuration, root, prefix string) []string { @@ -70,7 +76,7 @@ func findPrefixedPackages(config *core.Configuration, root, prefix string) []str var matchedPkgs []string for _, d := range dirs { if d.IsDir() && strings.HasPrefix(d.Name(), prefix) { - p := filepath.Join(root, d.Name()) + p := path.Join(root, d.Name()) if containsPackage(config, p) { matchedPkgs = append(matchedPkgs, p) } @@ -92,10 +98,10 @@ func getPackagesAndPackageToParse(config *core.Configuration, query string) ([]s prefix := "" if info, err := os.Lstat(root); err != nil || !info.IsDir() { _, prefix = filepath.Split(root) - currentPackage = filepath.Dir(query) + currentPackage = path.Dir(query) } else if !packageOnly { // If we match a package directly but that's also a prefix for another package, we should return those packages - root, prefix := filepath.Split(query) + root, prefix := path.Split(query) packages := findPrefixedPackages(config, root, prefix) if len(packages) > 1 { return packages, "" @@ -117,7 +123,7 @@ func isExcluded(config *core.Configuration, dir string) bool { return true } for _, blacklisted := range config.Parse.BlacklistDirs { - if filepath.Base(dir) == blacklisted { + if path.Base(dir) == blacklisted { return true } } @@ -140,7 +146,7 @@ func containsPackage(config *core.Configuration, dir string) bool { for _, info := range infos { if info.IsDir() { - dirQueue = append(dirQueue, filepath.Join(dir, info.Name())) + dirQueue = append(dirQueue, path.Join(dir, info.Name())) } if config.IsABuildFile(info.Name()) { return true diff --git a/src/query/graph.go b/src/query/graph.go index 4b24517827..4d293f55cb 100644 --- a/src/query/graph.go +++ b/src/query/graph.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "encoding/json" "os" - "path/filepath" + "path" "sync" "github.com/thought-machine/please/src/build" @@ -155,7 +155,7 @@ func makeJSONTarget(state *core.BuildState, target *core.BuildTarget) JSONTarget t.Inputs = append(t.Inputs, in) } for _, out := range target.Outputs() { - t.Outputs = append(t.Outputs, filepath.Join(target.Label.PackageName, out)) + t.Outputs = append(t.Outputs, path.Join(target.Label.PackageName, out)) } for _, dep := range target.Dependencies() { t.Deps = append(t.Deps, dep.Label.String()) diff --git a/src/query/outputs.go b/src/query/outputs.go index 3ab38034cb..da622836d8 100644 --- a/src/query/outputs.go +++ b/src/query/outputs.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" + "path" "github.com/thought-machine/please/src/core" ) @@ -22,7 +22,7 @@ func targetOutputsFlat(graph *core.BuildGraph, labels []core.BuildLabel) { for _, label := range labels { target := graph.TargetOrDie(label) for _, out := range target.Outputs() { - fmt.Printf("%s\n", filepath.Join(target.OutDir(), out)) + fmt.Printf("%s\n", path.Join(target.OutDir(), out)) } } } @@ -32,7 +32,7 @@ func targetOutputsJSON(graph *core.BuildGraph, labels []core.BuildLabel) { for _, label := range labels { target := graph.TargetOrDie(label) for _, out := range target.Outputs() { - data[label.String()] = append(data[label.String()], filepath.Join(target.OutDir(), out)) + data[label.String()] = append(data[label.String()], path.Join(target.OutDir(), out)) } } encoder := json.NewEncoder(os.Stdout) diff --git a/src/remote/BUILD b/src/remote/BUILD index 3c5343b2b6..d50e59dbdc 100644 --- a/src/remote/BUILD +++ b/src/remote/BUILD @@ -52,9 +52,11 @@ go_test( "impl_test.go", "remote_test.go", ], - data = ["test_data"], + data = [":remote_test_data"], # TODO(#1412): find out why this flakes on circle flaky = True, + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":remote", "///third_party/go/cloud.google.com_go_longrunning//autogen/longrunningpb", @@ -78,3 +80,11 @@ go_test( "//src/fs", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "remote_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/remote/action.go b/src/remote/action.go index 7832e1f8e2..51b49496f7 100644 --- a/src/remote/action.go +++ b/src/remote/action.go @@ -129,7 +129,7 @@ func (c *Client) buildCommand(target *core.BuildTarget, inputRoot *pb.Directory, cmd, err := core.ReplaceSequences(state, target, cmd) return &pb.Command{ Platform: c.targetPlatformProperties(target), //nolint:staticcheck - Arguments: process.BashCommand(c.shellPath, commandPrefixBuilder.String()+cmd, state.Config.Build.ExitOnError), + Arguments: process.RemoteBashCommand(c.shellPath, commandPrefixBuilder.String()+cmd, state.Config.Build.ExitOnError), EnvironmentVariables: c.buildEnv(target, c.stampedBuildEnvironment(state, target, inputRoot, stamp, isTest || isRun), target.Sandbox), OutputPaths: outs, }, err @@ -169,7 +169,7 @@ func (c *Client) buildTestCommand(state *core.BuildState, target *core.BuildTarg }, }, }, - Arguments: process.BashCommand(c.shellPath, commandPrefix+cmd, state.Config.Build.ExitOnError), + Arguments: process.RemoteBashCommand(c.shellPath, commandPrefix+cmd, state.Config.Build.ExitOnError), EnvironmentVariables: c.buildEnv(nil, core.TestEnvironment(state, target, ".", run), target.Test.Sandbox), OutputPaths: paths, }, err @@ -587,7 +587,9 @@ func (c *Client) buildEnv(target *core.BuildTarget, env core.BuildEnv, sandbox b if name == "PATH" { // Strip out anything prefixed with the local user's home directory; it can't be // useful remotely but will affect determinism of the action. - parts := strings.Split(v, ":") + // Note the asymmetry: we split with the local separator because the value was + // built locally, but rejoin with ":" because the worker is a POSIX machine. + parts := fs.SplitPathList(v) replaced := make([]string, 0, len(parts)) for _, part := range parts { if part != c.state.Config.Please.Location && !strings.HasPrefix(part, c.userHome) { diff --git a/src/run/BUILD b/src/run/BUILD index 0e4f5698f8..bfa89c8d94 100644 --- a/src/run/BUILD +++ b/src/run/BUILD @@ -1,6 +1,10 @@ go_library( name = "run", - srcs = ["run_step.go"], + srcs = [ + "env_other.go", + "env_windows.go", + "run_step.go", + ], pgo_file = "//:pgo", visibility = ["PUBLIC"], deps = [ @@ -17,11 +21,22 @@ go_library( go_test( name = "run_test", srcs = ["run_test.go"], - data = ["test_data"], + data = [":run_test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":run", "///third_party/go/github.com_stretchr_testify//assert", + "///third_party/go/github.com_stretchr_testify//require", "//src/core", "//src/process", ], ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "run_test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/run/env_other.go b/src/run/env_other.go new file mode 100644 index 0000000000..5ffb77493f --- /dev/null +++ b/src/run/env_other.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package run + +// envNamesEqual reports whether two environment variable names refer to the same variable. +// Unix environment names are case-sensitive, so this is plain equality. +func envNamesEqual(a, b string) bool { return a == b } diff --git a/src/run/env_windows.go b/src/run/env_windows.go new file mode 100644 index 0000000000..9c4fd59304 --- /dev/null +++ b/src/run/env_windows.go @@ -0,0 +1,12 @@ +package run + +import "strings" + +// envNamesEqual reports whether two environment variable names refer to the same variable. +// +// Windows environment names are case-insensitive, and the OS keeps its own spelling: setting +// PATH updates the variable it already has, which it stores as Path. Comparing names exactly +// therefore fails to find it, and a caller that meant to replace an entry appends a second one +// instead. os/exec happens to paper over that by deduplicating case-insensitively itself, but +// nothing else does - ExecReplace and the audit log both see the duplicate. +func envNamesEqual(a, b string) bool { return strings.EqualFold(a, b) } diff --git a/src/run/run_step.go b/src/run/run_step.go index 8c471997d6..2dce9be7c4 100644 --- a/src/run/run_step.go +++ b/src/run/run_step.go @@ -9,7 +9,6 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "time" "golang.org/x/sync/errgroup" @@ -131,7 +130,7 @@ func run(ctx context.Context, state *core.BuildState, label core.AnnotatedOutput case overrideCmd != "": command, _ := core.ReplaceSequences(state, target, overrideCmd) // We don't care about passed in args when an override command is provided - args = process.BashCommand("bash", strings.Trim(command, "\""), true) + args = state.ProcessExecutor.BashCommand(strings.Trim(command, "\""), true) case label.Annotation != "": entryPoint, ok := target.EntryPoints[label.Annotation] if !ok { @@ -157,11 +156,11 @@ func run(ctx context.Context, state *core.BuildState, label core.AnnotatedOutput } // Handle targets where $(exe ...) returns something nontrivial - if !strings.Contains(args[0], "/") { + if !strings.Contains(args[0], "/") && !strings.ContainsRune(args[0], filepath.Separator) { // Probably it's a java -jar, we need an absolute path to it. cmd, err := exec.LookPath(args[0]) if err != nil { - log.Fatalf("Can't find binary %s", args[0]) + log.Fatalf("Can't find binary %s%s", args[0], fs.ExplainUnrunnable(args[0])) } args[0] = cmd } else if dir != "" { // Find an absolute path before changing directory @@ -172,19 +171,26 @@ func run(ctx context.Context, state *core.BuildState, label core.AnnotatedOutput args[0] = abs } + // The path Please built is slash-separated, and on Windows that is not merely untidy. A + // .cmd - which is what an sh_binary is there - runs through cmd.exe, and cmd.exe reads a + // forward slash as the start of a switch: plz-out/bin/x.cmd is the command "plz-out" with + // two switches, and it says so. Wine's cmd is more forgiving, which is why this only + // showed up on a real machine. + args[0] = filepath.FromSlash(args[0]) + log.Info("Running target %s...", strings.Join(args, " ")) output.SetWindowTitle("plz run: " + strings.Join(args, " ")) env := environ(state, target, setenv, tmpDir) if !fork { if dir != "" { - err := syscall.Chdir(dir) + err := os.Chdir(dir) if err != nil { log.Fatalf("Error changing directory %s: %s", dir, err) } } // Plain 'plz run'. One way or another we never return from the following line. - must(syscall.Exec(args[0], args, env), args) + must(process.ExecReplace(args[0], args, env), args) } else if detach { // Bypass the whole process management system since we explicitly aim not to manage this subprocess. cmd := exec.Command(args[0], args[1:]...) @@ -251,8 +257,11 @@ func addEnv(env []string, e core.BuildEnv) []string { func addOneEnv(env []string, k, v string) []string { for i, existing := range env { - if strings.HasPrefix(existing, k+"=") { - env[i] = k + "=" + v + if name, _, ok := strings.Cut(existing, "="); ok && envNamesEqual(name, k) { + // The OS's own spelling of the name is kept, not ours. On Windows they differ - + // PATH is stored as Path - and rewriting it here would leave two entries for one + // variable in anything that reads this slice without deduplicating. + env[i] = name + "=" + v return env } } @@ -262,7 +271,7 @@ func addOneEnv(env []string, k, v string) []string { // must dies if the given error is non-nil. func must(err error, cmd []string) { if err != nil { - log.Fatalf("Error running command %s: %s", strings.Join(cmd, " "), err) + log.Fatalf("Error running command %s: %s%s", strings.Join(cmd, " "), err, fs.ExplainUnrunnable(cmd[0])) } } @@ -272,11 +281,7 @@ func toExitError(err error, cmd []string, out []byte) error { if err == nil { return nil } else if exitError, ok := err.(*exec.ExitError); ok { - // This is a little hairy; there isn't a good way of getting the exit code, - // but this should be reasonably portable (at least to the platforms we care about). - if status, ok := exitError.Sys().(syscall.WaitStatus); ok { - exitCode = status.ExitStatus() - } + exitCode = exitError.ExitCode() } return &exitError{ msg: fmt.Sprintf("Error running command %s: %s\n%s", strings.Join(cmd, " "), err, string(out)), diff --git a/src/run/run_test.go b/src/run/run_test.go index 4cdcccfe19..272987d538 100644 --- a/src/run/run_test.go +++ b/src/run/run_test.go @@ -3,9 +3,12 @@ package run import ( "context" "os" + "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/thought-machine/please/src/core" "github.com/thought-machine/please/src/process" @@ -17,6 +20,19 @@ func init() { } } +// runnable returns the fixture name that this platform can actually execute. +// +// The Unix fixtures are shell scripts relying on a #! line, and Windows has no such mechanism: +// it decides what is executable by extension. The .cmd files beside them are the same two +// programs written the only way Windows will run one by name - which is exactly what the shell +// plugin does for an sh_binary there. +func runnable(name string) string { + if runtime.GOOS == "windows" { + return name + ".cmd" + } + return name +} + func TestSequential(t *testing.T) { state, labels1, labels2 := makeState(core.DefaultConfiguration()) code := Sequential(state, labels1, nil, process.Quiet, false, false, false, "") @@ -38,25 +54,46 @@ func TestEnvVars(t *testing.T) { config.Build.Path = []string{"/wibble"} state, lab1, _ := makeState(config) - t.Setenv("PATH", "/usr/local/bin:/usr/bin:/bin") + // Built rather than written out: the separator between entries differs per platform, and + // so does what Please prepends - its own location, which is empty in this state. + sep := string(os.PathListSeparator) + hostPath := strings.Join([]string{"/usr/local/bin", "/usr/bin", "/bin"}, sep) + + t.Setenv("PATH", hostPath) env := environ(state, state.Graph.TargetOrDie(lab1[0].BuildLabel), false, false) - assert.Contains(t, env, "PATH=/usr/local/bin:/usr/bin:/bin") - assert.NotContains(t, env, "PATH=/wibble") + assert.Equal(t, hostPath, envValue(t, env, "PATH")) env = environ(state, state.Graph.TargetOrDie(lab1[0].BuildLabel), true, false) - assert.NotContains(t, env, "PATH=/usr/local/bin:/usr/bin:/bin") - assert.Contains(t, env, "PATH=:/wibble", env) + assert.Equal(t, sep+"/wibble", envValue(t, env, "PATH")) +} + +// envValue returns the value of one variable, and asserts there is exactly one entry for it. +// +// Looked up by name rather than matched as a whole string, because the OS decides how the name +// is spelled: Windows stores PATH as Path, so asserting on the literal "PATH=" finds nothing. +// The count is the point of the second assertion - appending a second entry instead of +// replacing the first is the bug this guards, and os/exec hides it by deduplicating. +func envValue(t *testing.T, env []string, name string) string { + t.Helper() + var values []string + for _, entry := range env { + if k, v, ok := strings.Cut(entry, "="); ok && envNamesEqual(k, name) { + values = append(values, v) + } + } + require.Len(t, values, 1, "expected exactly one %s in %v", name, env) + return values[0] } func makeState(config *core.Configuration) (*core.BuildState, []core.AnnotatedOutputLabel, []core.AnnotatedOutputLabel) { state := core.NewBuildState(config) target1 := core.NewBuildTarget(core.ParseBuildLabel("//:true", "")) target1.IsBinary = true - target1.AddOutput("true") + target1.AddOutput(runnable("true")) target1.Test = new(core.TestFields) state.Graph.AddTarget(target1) target2 := core.NewBuildTarget(core.ParseBuildLabel("//:false", "")) target2.IsBinary = true - target2.AddOutput("false") + target2.AddOutput(runnable("false")) target2.Test = new(core.TestFields) state.Graph.AddTarget(target2) return state, annotate([]core.BuildLabel{target1.Label}), annotate([]core.BuildLabel{target1.Label, target2.Label}) diff --git a/src/run/test_data/plz-out/bin/false.cmd b/src/run/test_data/plz-out/bin/false.cmd new file mode 100644 index 0000000000..b1dfd7df99 --- /dev/null +++ b/src/run/test_data/plz-out/bin/false.cmd @@ -0,0 +1 @@ +@exit /b 1 diff --git a/src/run/test_data/plz-out/bin/true.cmd b/src/run/test_data/plz-out/bin/true.cmd new file mode 100644 index 0000000000..8c36896533 --- /dev/null +++ b/src/run/test_data/plz-out/bin/true.cmd @@ -0,0 +1 @@ +@exit /b 0 diff --git a/src/test/BUILD b/src/test/BUILD index f3bb830d21..6faf1fa5f9 100644 --- a/src/test/BUILD +++ b/src/test/BUILD @@ -36,7 +36,9 @@ go_test( "results_test.go", "xml_results_test.go", ], - data = ["test_data"], + data = [":test_data"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":test", "///third_party/go/github.com_peterebden_tools//cover", @@ -63,3 +65,11 @@ plz_e2e_test( expect_output_contains = "panic: goodbye world", expected_failure = True, ) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/src/test/coverage.go b/src/test/coverage.go index 74a4f39d41..219a84292b 100644 --- a/src/test/coverage.go +++ b/src/test/coverage.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "strings" @@ -202,7 +203,10 @@ func getDirectoryCoverage(coverage core.TestCoverage) map[string]float32 { for file, coverage := range coverage.Files { covered, total := CountCoverage(coverage) - dirpath := filepath.Dir(file) + // path, not filepath: these are repo-relative names from a coverage file, and the + // result is reported to the user and matched against configured paths, both of which + // are slash-separated. + dirpath := path.Dir(file) if _, exists := linesByDir[dirpath]; exists { linesByDir[dirpath].covered += covered diff --git a/src/test/istanbul_coverage.go b/src/test/istanbul_coverage.go index 3bddcff597..47f3d68cb3 100644 --- a/src/test/istanbul_coverage.go +++ b/src/test/istanbul_coverage.go @@ -5,6 +5,7 @@ package test import ( "bytes" "encoding/json" + "path" "path/filepath" "strings" @@ -95,13 +96,18 @@ func sanitiseFileName(target *core.BuildTarget, filename string, run int) string // It returns a non-empty string if successful. // If matchAnyLastDir is true it will match any directory for the last component. func sanitiseFileNameDir(filename string, dir string, matchAnyLastDir bool) string { + // Compared as slash paths throughout. The directories are plz-out paths, which are + // slash-separated on every platform, but the filename comes out of a coverage file that + // some other tool wrote and may use either separator. + filename = filepath.ToSlash(filename) + dir = filepath.ToSlash(dir) if matchAnyLastDir { - dir = filepath.Dir(dir) + dir = path.Dir(dir) } if index := strings.Index(filename, dir); index != -1 { ret := filename[index+len(dir)+1:] if matchAnyLastDir { - if index := strings.IndexRune(ret, filepath.Separator); index != -1 { + if index := strings.IndexRune(ret, '/'); index != -1 { return ret[index+1:] } } diff --git a/src/test/xml_coverage.go b/src/test/xml_coverage.go index b43fe7806f..1014eeb6d2 100644 --- a/src/test/xml_coverage.go +++ b/src/test/xml_coverage.go @@ -6,7 +6,6 @@ import ( "encoding/xml" "math" "path/filepath" - "strings" "time" "github.com/thought-machine/please/src/cli" @@ -20,7 +19,7 @@ func parseXMLCoverageResults(target *core.BuildTarget, coverage *core.TestCovera } for _, pkg := range xcoverage.Packages.Package { for _, cls := range pkg.Classes.Class { - filename := strings.TrimPrefix(cls.Filename, core.RepoRoot) + filename := core.TrimRepoRoot(cls.Filename) // There can be multiple classes per file so we must merge here, not overwrite. coverage.Files[filename] = core.MergeCoverageLines(coverage.Files[filename], parseXMLLines(cls.Lines.Line)) } diff --git a/src/tool/BUILD b/src/tool/BUILD index cc2ba89cf4..d26a2140e3 100644 --- a/src/tool/BUILD +++ b/src/tool/BUILD @@ -8,12 +8,15 @@ go_library( "//src/cli/logging", "//src/core", "//src/fs", + "//src/process", ], ) go_test( name = "tool_test", srcs = ["tool_test.go"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":tool", "///third_party/go/github.com_stretchr_testify//assert", diff --git a/src/tool/tool.go b/src/tool/tool.go index e3a830bfa2..986b723166 100644 --- a/src/tool/tool.go +++ b/src/tool/tool.go @@ -10,13 +10,13 @@ import ( "path/filepath" "sort" "strings" - "syscall" "github.com/thought-machine/go-flags" "github.com/thought-machine/please/src/cli/logging" "github.com/thought-machine/please/src/core" "github.com/thought-machine/please/src/fs" + "github.com/thought-machine/please/src/process" ) var log = logging.Log @@ -45,7 +45,7 @@ func Run(config *core.Configuration, tool Tool, args []string) { target = t } // Hopefully we have an absolute path now, so let's run it. - err := syscall.Exec(target, append([]string{target}, args...), os.Environ()) + err := process.ExecReplace(target, append([]string{target}, args...), os.Environ()) log.Fatalf("Failed to exec %s: %s", target, err) // Always a failure, exec never returns. } diff --git a/src/update/BUILD b/src/update/BUILD index 7fffc5204b..37a3dbb58c 100644 --- a/src/update/BUILD +++ b/src/update/BUILD @@ -4,6 +4,8 @@ go_library( name = "update", srcs = [ "clean.go", + "link_other.go", + "link_windows.go", "update.go", "verify.go", ], @@ -32,7 +34,7 @@ go_test( "verify_test.go", ], data = [ - "test_data", + ":test_data", ":test_please", ":test_tarball", "//src:please", @@ -42,6 +44,8 @@ go_test( }, labels = ["xz"], resources = ["key.pub"], + # So //test/windows can run this same binary, cross-built, under Wine. + visibility = ["//test/windows:all"], deps = [ ":update", "///third_party/go/github.com_hashicorp_go-retryablehttp//:go-retryablehttp", @@ -61,6 +65,8 @@ tarball( out = "please_test.tar.gz", subdir = "please", test_only = True, + # So //test/windows can put it beside the test binary when running it under Wine. + visibility = ["//test/windows:all"], ) genrule( @@ -70,4 +76,14 @@ genrule( binary = True, cmd = "cp $SRC $OUT", test_only = True, + # So //test/windows can put it beside the test binary when running it under Wine. + visibility = ["//test/windows:all"], +) + +# Exposed so //test/windows can put it beside the test binary when running it under Wine. +filegroup( + name = "test_data", + srcs = ["test_data"], + test_only = True, + visibility = ["//test/windows:all"], ) diff --git a/src/update/clean.go b/src/update/clean.go index ea5088c212..5d74142832 100644 --- a/src/update/clean.go +++ b/src/update/clean.go @@ -15,6 +15,10 @@ import ( // clean checks for any stale versions in the download directory and wipes them out if OK. func clean(config *core.Configuration, manualUpdate bool) { + // Anything an update couldn't replace because it was running at the time is still lying + // around under a .stale name; it will be free now. + cleanStaleFiles(config.Please.Location) + dir, _ := os.ReadDir(config.Please.Location) versions := make(semver.Versions, 0, len(dir)) // Convert these to semver diff --git a/src/update/link_other.go b/src/update/link_other.go new file mode 100644 index 0000000000..1d1d17384f --- /dev/null +++ b/src/update/link_other.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package update + +import ( + "os" + + "github.com/thought-machine/please/src/fs" +) + +// linkFile points globalFile at downloadedFile, replacing whatever was there before. +func linkFile(downloadedFile, globalFile string) error { + if err := fs.RemoveAll(globalFile); err != nil { + return err + } + return os.Symlink(downloadedFile, globalFile) +} + +// cleanStaleFiles does nothing here; only Windows can fail to replace a file and have to +// leave the old one behind. +func cleanStaleFiles(string) {} diff --git a/src/update/link_windows.go b/src/update/link_windows.go new file mode 100644 index 0000000000..77a523f0ae --- /dev/null +++ b/src/update/link_windows.go @@ -0,0 +1,62 @@ +package update + +import ( + "os" + "path/filepath" + "strings" + + "github.com/thought-machine/please/src/fs" +) + +// staleSuffix marks a file that was still in use when we tried to replace it. +const staleSuffix = ".stale" + +// linkFile points globalFile at downloadedFile, replacing whatever was there before. +// +// Windows makes this harder than it is elsewhere, in two ways. Symlinks need Developer Mode or +// SeCreateSymbolicLinkPrivilege, which an ordinary user does not have, so we hard-link +// instead; that behaves the same for our purposes and needs no privilege on NTFS. And a +// running executable can be neither deleted nor written over, which matters because the file +// we are most often replacing is the Please that is doing the replacing. Windows does allow +// it to be renamed, so we move it aside and let a later run clear up. +func linkFile(downloadedFile, globalFile string) error { + if err := removeOrRenameAside(globalFile); err != nil { + return err + } + // Hard links fail across volumes and on filesystems that don't have them, so fall back to + // a copy; it costs disk space but is always available. + return fs.CopyOrLinkFile(downloadedFile, globalFile, 0555, 0555, true, true) +} + +// removeOrRenameAside deletes a file, or renames it out of the way if it is in use. +func removeOrRenameAside(path string) error { + if !fs.PathExists(path) { + return nil + } + if err := fs.RemoveAll(path); err == nil { + return nil + } + stale := path + staleSuffix + // A previous update may have left one of these; it's fine if that one is still held too. + if err := fs.RemoveAll(stale); err != nil { + log.Debug("Couldn't remove %s: %s", stale, err) + } + log.Debug("Can't remove %s, renaming it to %s", path, stale) + return os.Rename(path, stale) +} + +// cleanStaleFiles removes anything an earlier update had to rename aside because it was in +// use at the time. Failures are expected and ignored; it may still be in use now. +func cleanStaleFiles(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), staleSuffix) { + if err := fs.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + log.Debug("Couldn't remove stale file %s: %s", entry.Name(), err) + } + } + } +} diff --git a/src/update/update.go b/src/update/update.go index 81220ec50b..d318e2069b 100644 --- a/src/update/update.go +++ b/src/update/update.go @@ -19,7 +19,6 @@ import ( "runtime" "strconv" "strings" - "syscall" "github.com/coreos/go-semver/semver" "github.com/hashicorp/go-retryablehttp" @@ -42,6 +41,9 @@ var httpClient *retryablehttp.Client const milestoneURL = "https://please.build/milestones" +// pleaseExeName is what the Please binary is called inside a version directory. +const pleaseExeName = "please" + fs.ExeSuffix + // pleaseVersion returns the current version of Please as a semver. func pleaseVersion() semver.Version { return *semver.New(version.PleaseVersion) @@ -95,7 +97,11 @@ func CheckAndUpdate(config *core.Configuration, updatesEnabled, updateCommand, f core.ReturnToInitialWorkingDir() args := filterArgs(forceUpdate, append([]string{newPlease}, os.Args[1:]...)) log.Info("Executing %s", strings.Join(args, " ")) - if err := syscall.Exec(newPlease, args, os.Environ()); err != nil { + // Release the repo lock before handing over. On Unix the exec would drop it for us, since + // Go opens files O_CLOEXEC; on Windows we stay alive as the new process's parent and would + // otherwise deadlock it against ourselves. + core.ReleaseRepoLock() + if err := process.ExecReplace(newPlease, args, os.Environ()); err != nil { log.Fatalf("Failed to exec new Please version %s: %s", newPlease, err) } // Shouldn't ever get here. We should have either exec'd or died above. @@ -184,7 +190,7 @@ func shouldUpdate(config *core.Configuration, updatesEnabled, updateCommand, pre // downloadAndLinkPlease downloads a new Please version and links it into place, if needed. // It returns the new location and dies on failure. func downloadAndLinkPlease(config *core.Configuration, verify bool, progress bool) string { - newPlease := filepath.Join(config.Please.Location, config.Please.Version.VersionString(), "please") + newPlease := filepath.Join(config.Please.Location, config.Please.Version.VersionString(), pleaseExeName) if !core.PathExists(newPlease) { downloadPlease(config, verify, progress) @@ -259,7 +265,7 @@ func copyFile(r io.Reader, newDir string) { if err := os.MkdirAll(newDir, fs.DirPermissions); err != nil { panic(err) } - f, err := os.OpenFile(filepath.Join(newDir, "please"), os.O_RDWR|os.O_CREATE, 0555) + f, err := os.OpenFile(filepath.Join(newDir, pleaseExeName), os.O_RDWR|os.O_CREATE, 0555) if err != nil { panic(err) } @@ -315,10 +321,7 @@ func linkNewFile(config *core.Configuration, file string) { newDir := filepath.Join(config.Please.Location, config.Please.Version.VersionString()) globalFile := filepath.Join(config.Please.Location, file) downloadedFile := filepath.Join(newDir, file) - if err := fs.RemoveAll(globalFile); err != nil { - log.Fatalf("Failed to remove existing file %s: %s", globalFile, err) - } - if err := os.Symlink(downloadedFile, globalFile); err != nil { + if err := linkFile(downloadedFile, globalFile); err != nil { log.Fatalf("Error linking %s -> %s: %s", downloadedFile, globalFile, err) } log.Info("Linked %s -> %s", globalFile, downloadedFile) diff --git a/src/update/update_test.go b/src/update/update_test.go index 2f79eeca9e..5a7149483a 100644 --- a/src/update/update_test.go +++ b/src/update/update_test.go @@ -31,8 +31,13 @@ func (*fakeLogBackend) Log(level logging.Level, calldepth int, rec *logging.Reco } func TestVerifyNewPlease(t *testing.T) { - assert.True(t, verifyNewPlease("src/please", version.PleaseVersion)) - assert.False(t, verifyNewPlease("src/please", "wibble")) + // Windows decides what it can run by extension, so the binary is named with one there. + please := "src/please" + if runtime.GOOS == "windows" { + please += ".exe" + } + assert.True(t, verifyNewPlease(please, version.PleaseVersion)) + assert.False(t, verifyNewPlease(please, "wibble")) assert.False(t, verifyNewPlease("wibble", version.PleaseVersion)) } @@ -66,7 +71,9 @@ func TestDownloadNewPlease(t *testing.T) { c := makeConfig("downloadnewplease") downloadPlease(c, false, true) // Should have written new file - assert.True(t, core.PathExists(filepath.Join(c.Please.Location, c.Please.Version.String(), "please"))) + // pleaseExeName rather than "please": the downloaded binary needs the extension on Windows + // or nothing will run it. + assert.True(t, core.PathExists(filepath.Join(c.Please.Location, c.Please.Version.String(), pleaseExeName))) // Should not have written this yet though assert.False(t, core.PathExists(filepath.Join(c.Please.Location, "please"))) // Panics because it's not a valid .tar.gz diff --git a/src/watch/BUILD b/src/watch/BUILD index 5dcfcffaf0..e8461e0c43 100644 --- a/src/watch/BUILD +++ b/src/watch/BUILD @@ -13,3 +13,15 @@ go_library( "//src/run", ], ) + +go_test( + name = "watch_test", + srcs = ["watch_test.go"], + # So //test/windows can run this same binary, cross-built, under Wine, which is the only + # platform where the assertion means anything. + visibility = ["//test/windows:all"], + deps = [ + ":watch", + "///third_party/go/github.com_stretchr_testify//assert", + ], +) diff --git a/src/watch/watch.go b/src/watch/watch.go index 3d7112f595..08371fb1b8 100644 --- a/src/watch/watch.go +++ b/src/watch/watch.go @@ -4,6 +4,7 @@ package watch import ( "context" "fmt" + "path" "path/filepath" "sync" "time" @@ -59,7 +60,7 @@ func Watch(state *core.BuildState, labels core.BuildLabels, testArgs []string, n select { case event := <-watcher.Events: log.Info("Event: %s", event) - if _, present := files.Load(event.Name); !present { + if _, present := files.Load(watchKey(event.Name)); !present { log.Notice("Skipping notification for %s", event.Name) continue } @@ -123,17 +124,27 @@ func startWatching(watcher *fsnotify.Watcher, state *core.BuildState, labels []c fmt.Println("And now my watch begins...") } +// watchKey normalises a path so that the names we record and the names fsnotify reports back +// can be compared. On Windows they need not agree otherwise: our sources are slash-separated, +// while anything that has been through filepath, or that fsnotify built from a watched +// directory, comes back with backslashes. A mismatch is silent - every event is discarded as +// belonging to a file we aren't watching - so `plz watch` would simply never fire. +func watchKey(path string) string { + return filepath.ToSlash(path) +} + func addSource(watcher *fsnotify.Watcher, state *core.BuildState, source core.BuildInput, dirs map[string]struct{}, files *sync.Map) { if _, ok := source.Label(); !ok { for _, src := range source.Paths(state.Graph) { if err := fs.Walk(src, func(src string, isDir bool) error { + src = watchKey(src) files.Store(src, struct{}{}) if !filepath.IsAbs(src) { files.Store("./"+src, struct{}{}) } dir := src if !isDir { - dir = filepath.Dir(src) + dir = path.Dir(src) } if _, present := dirs[dir]; !present { log.Notice("Adding watch on %s", dir) diff --git a/src/watch/watch_test.go b/src/watch/watch_test.go new file mode 100644 index 0000000000..f7c9c2a839 --- /dev/null +++ b/src/watch/watch_test.go @@ -0,0 +1,20 @@ +package watch + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWatchKeyMatchesNativeSeparators(t *testing.T) { + // The two sides of the comparison come from different places - our own slash-separated + // source paths, and whatever fsnotify reports, which on Windows uses backslashes - so + // they have to normalise to the same thing whichever separator each arrived with. + assert.Equal(t, watchKey("src/core/foo.go"), watchKey(filepath.Join("src", "core", "foo.go"))) +} + +func TestWatchKeyIsIdempotent(t *testing.T) { + once := watchKey(filepath.Join("src", "core", "foo.go")) + assert.Equal(t, once, watchKey(once)) +} diff --git a/test.sh b/test.sh index feed5274ee..c2ce0f2aef 100755 --- a/test.sh +++ b/test.sh @@ -25,6 +25,11 @@ check_path_for_excludes() { echo $EXCLUDES } +# has_wine reports whether the Windows tests can run here. +has_wine() { + hash wine 2>/dev/null +} + # Run the tests to make sure they still work notice "Running tests..." @@ -34,9 +39,19 @@ eval `go env` # repo that are optional and exercise specific rules, and require extra dependencies. EXCLUDES=$(check_path_for_excludes) -plz-out/bin/src/please -p -v2 $PLZ_ARGS ${PLZ_COVER:-test} $EXCLUDES --exclude=e2e --log_file plz-out/log/test_build.log --log_file_level 4 --trace_file plz-out/log/trace.json $@ +plz-out/bin/src/please -p -v2 $PLZ_ARGS ${PLZ_COVER:-test} $EXCLUDES --exclude=e2e --exclude=wine --log_file plz-out/log/test_build.log --log_file_level 4 --trace_file plz-out/log/trace.json $@ # We run the end-to-end tests separately to ensure things don't fight with one another; they are # finicky about some things due to running plz recursively and disabling the lock. notice "Running end-to-end tests..." -plz-out/bin/src/please -p -v2 $PLZ_ARGS ${PLZ_COVER:-test} $EXCLUDES --include=e2e --log_file plz-out/log/e2e_build.log --log_file_level 4 $@ +plz-out/bin/src/please -p -v2 $PLZ_ARGS ${PLZ_COVER:-test} $EXCLUDES --include=e2e --exclude=wine --log_file plz-out/log/e2e_build.log --log_file_level 4 $@ + +# The Windows tests cross-compile for windows_amd64 and run the result under Wine. They are a +# third pass because they are the only thing that builds the Go standard library for another +# platform, which is slow and pointless for someone who just wants the unit tests. +if has_wine; then + notice "Running Windows tests under Wine..." + plz-out/bin/src/please -p -v2 $PLZ_ARGS ${PLZ_COVER:-test} $EXCLUDES --include=wine --log_file plz-out/log/wine_build.log --log_file_level 4 $@ +else + warn "wine not found, skipping the Windows tests" +fi diff --git a/test/build_defs/BUILD b/test/build_defs/BUILD index 46e36c7918..debcfd34b6 100644 --- a/test/build_defs/BUILD +++ b/test/build_defs/BUILD @@ -17,6 +17,18 @@ filegroup( ], ) +filegroup( + name = "wine", + srcs = ["wine.build_defs"], + visibility = ["//test/..."], +) + +filegroup( + name = "windows_bundle", + srcs = ["windows_bundle.build_defs"], + visibility = ["//test/..."], +) + filegroup( name = "base_config", srcs = [ diff --git a/test/build_defs/windows_bundle.build_defs b/test/build_defs/windows_bundle.build_defs new file mode 100644 index 0000000000..d88ef21131 --- /dev/null +++ b/test/build_defs/windows_bundle.build_defs @@ -0,0 +1,94 @@ +# Packages the cross-built Windows test binaries so a real Windows machine can run them. +# +# The port's whole problem is that everything it claims rests on Wine. These are the same +# binaries //test/windows already runs under Wine; shipping them to a windows-latest runner +# turns that into evidence from Windows for almost no new test code. See +# docs/design/windows/05-testing-strategy.md for the list of things Wine cannot show. +# +# Everything here is labelled wine as well as windows, not because any of it runs under Wine +# but because building it means cross-compiling for Windows, which is what that label gates. + +def _windows_test_dir(name:str, test:str, data:list, needs_shell:bool): + """Stages one cross-built Go test binary with its data where the test expects to find it. + + The data lands correctly for free. An architecture subrepo has an empty root and package + root, so ///windows_amd64//src/fs:test_data stages at src/fs/test_data - which is the + literal path the test opens - and cp --parents relocates that subtree intact. + """ + srcs = {"BINARY": [test]} + cmds = [ + 'mkdir -p "$OUT"', + # Renamed for the same reason wine_go_test renames it: Go's exec will not run a file + # whose name has no extension in PATHEXT, even when handed its full path. + 'cp "$SRCS_BINARY" "$OUT/test.exe"', + ] + if data: + srcs["DATA"] = data + cmds.append('cp -r --parents $SRCS_DATA "$OUT/"') + + # What the runner must set $DATA to. Not knowable at parse time - these are the staged + # paths of another rule's outputs - so it is recorded here, where they are known. + cmds.append('echo "$SRCS_DATA" > "$OUT/DATA.txt"') + if needs_shell: + cmds.append('touch "$OUT/NEEDS_SHELL"') + return genrule( + name = f"_{name}#bundle", + srcs = srcs, + outs = [f"tests/{name}"], + cmd = " && ".join(cmds), + labels = ["wine", "windows"], + test_only = True, + ) + +def windows_test_bundle(name:str, tests:list, visibility:list=None): + """Packages the cross-built Windows test binaries for a real Windows machine to run. + + Every entry becomes tests//, holding test.exe, that test's data at the relative paths + it expects, and marker files saying what environment it needs. + + A directory per test rather than one shared tree, for three reasons. The data is a couple + of hundred megabytes, so copying it per test on the runner to isolate them would be most of + a gigabyte of IO. The tests have to be isolated: running these binaries in a shared tree + once deleted the whole of test/. And several of them want the harness directory itself + rather than only their data. + + The result is a directory rather than an archive because actions/upload-artifact zips + whatever it is handed. + + Args: + name (str): Name of the rule. + tests (list): (name, test target, data, needs_shell) tuples, the same list //test/windows + hands to wine_go_test. + visibility (list): Visibility declaration of the rule. + """ + dirs = [_windows_test_dir(n, t, d, s) for n, t, d, s in tests] + + # One test name per line, in the order this file declares them. Parse-time facts only; the + # build-time ones are the marker files beside each binary. A test silently disappearing + # from the list is then something the runner can notice. + manifest = "\n".join([n for n, _, _, _ in tests]) + return genrule( + name = name, + srcs = { + "DIRS": dirs, + "SHELL": ["///windows_amd64//third_party/binary:busybox"], + }, + outs = [name], + cmd = " && ".join([ + 'mkdir -p "$OUT/shell"', + # -l because these are already hardlinks into plz-out, and this is a few hundred + # megabytes that does not need copying twice. + 'cp -rl test/windows/tests "$OUT/tests"', + 'cp "$SRCS_SHELL" "$OUT/shell/busybox.exe"', + # A repo root above every test directory. Several of these tests call + # MustFindRepoRoot, and under Wine they get one by accident, because the harness + # runs them inside this repo's own plz-out. Natively the bundle sits wherever the + # runner unpacked it, with nothing above it, so without this they fail on a missing + # root rather than on whatever they are about. + 'echo "; Marks the bundle as a repo, for the tests that expect to be in one." > "$OUT/.plzconfig"', + f'printf "%s\\n" "{manifest}" > "$OUT/manifest.txt"', + ]), + labels = ["wine", "windows"], + test_only = True, + visibility = visibility, + ) diff --git a/test/build_defs/wine.build_defs b/test/build_defs/wine.build_defs new file mode 100644 index 0000000000..6dd3ed81ae --- /dev/null +++ b/test/build_defs/wine.build_defs @@ -0,0 +1,315 @@ +# Test macros that run Windows binaries under Wine. +# +# The port is developed and built on Linux, so these are how anything about its *runtime* +# behaviour gets checked at all before a Windows machine exists. See +# docs/design/windows/05-testing-strategy.md, which is honest about what Wine does and does +# not cover. +# +# Everything here is labelled 'wine' and excluded from the default test run, because building +# it means cross-compiling the Go standard library for Windows, which is a lot of work to +# impose on someone who only wanted to run the unit tests. + +# Wine's first run initialises a prefix, which is slow and produces a few hundred MB. One +# shared prefix for all these tests is enough - wineserver serialises access to it. $TMP_DIR +# is /plz-out/tmp/..., so trimming from the last /plz-out/ gets back to the root. +# This has to be built in the command rather than passed in env, which isn't expanded. +WINEPREFIX = "${TMP_DIR%/plz-out/*}/plz-out/wineprefix" + +# WINEDEBUG=-all silences Wine's own chatter, which would otherwise be interleaved with the +# test output we are trying to parse. +WINE_ENV = {"WINEDEBUG": "-all"} + +def _wine_setup_cmd(): + """Returns a command that prepares the shared Wine prefix and isolates this test in it.""" + + hosts = '"$WINEPREFIX/drive_c/windows/system32/drivers/etc/hosts"' + return " && ".join([ + f'export WINEPREFIX="{WINEPREFIX}"', + # wineboot is idempotent but not free, so only run it if the prefix isn't there. Two + # tests racing to create it is fine; wineserver serialises access. + 'if [ ! -d "$WINEPREFIX" ]; then wineboot --init >/dev/null 2>&1 || true; fi', + # Wine ships a hosts file with the localhost line commented out, so anything that + # resolves it - a test's own HTTP server, say - hangs until it gives up. + f'grep -q "^127.0.0.1 localhost" {hosts} || echo "127.0.0.1 localhost" >> {hosts}', + # Point the Windows home directory at this test's own temp dir. Otherwise every test + # shares the one inside the prefix and anything written to ~ leaks between runs; a + # read-only file left there can't even be replaced on Windows. + 'export USERPROFILE="$(winepath -w "$TMP_DIR")"', + ]) + +def wine_go_test( + name:str, + test:str, + data:list=[], + needs_shell:bool=False, + labels:list=[], + timeout:int=600, + size:str=None): + """Runs a Go test binary that was cross-compiled for Windows, under Wine. + + Args: + name (str): Name of the rule. + test (str): The go_test target to run, which must be in the windows_amd64 architecture - + i.e. a label of the form ///windows_amd64//src/core:core_test. + data (list): Runtime data the test needs. A go_test's own data doesn't come along when + another rule depends on it, so anything the test reads has to be repeated + here; it lands at the same path it would have under go_test. + needs_shell (bool): True if the test runs build actions, which need a shell. Puts the + bundled busybox next to the test binary, which is where Please looks for it + when it isn't on the PATH - the same arrangement as an install. + labels (list): Extra labels for the rule. + timeout (int): Test timeout in seconds. Wine is slower than native, and these binaries + are being run cold. + size (str): Test size. + """ + + # The binary has to be renamed. The go plugin names its output after the rule, so the + # cross-built binary is called e.g. 'core_test' with no extension - and Go's exec package + # on Windows will not run a file whose name has no extension in PATHEXT, even when handed + # its full path. Any test whose subject re-execs itself fails obscurely without this. + cmds = [ + _wine_setup_cmd(), + 'cp "$DATA_TEST_BINARY" "$TMP_DIR/test.exe"', + # A go_test's $DATA names only its data; here it would also name the test binary, which + # is not something the test put there. Tests that read $DATA expect the former. + # :- because the shell runs with -u and this is unset when the test has no data. + 'export DATA="${DATA_FILES:-}"', + ] + if needs_shell: + # In a directory of its own, on the Windows PATH, rather than in the working directory: + # Go's exec refuses to run something it found relative to the current directory, and + # this is how an install has it anyway. WINEPATH is what Wine adds to the Windows PATH. + cmds.append('mkdir -p "$TMP_DIR/shell" && cp "$DATA_BUSYBOX" "$TMP_DIR/shell/busybox.exe"') + cmds.append('export WINEPATH="$(winepath -w "$TMP_DIR/shell")"') + + # Go test binaries print in the format Please parses when it isn't given JUnit XML. + cmds.append('wine "$TMP_DIR/test.exe" -test.v 2>&1 | tee "$TMP_DIR/test.results"') + test_cmd = " && ".join(cmds) + + test_data = {"TEST_BINARY": [test], "FILES": data} + if needs_shell: + test_data["BUSYBOX"] = ["///windows_amd64//third_party/binary:busybox"] + return gentest( + name = name, + size = size, + timeout = timeout, + data = test_data, + env = WINE_ENV, + labels = labels + ["wine", "windows"], + local = True, + # Wine needs a real filesystem it can put a prefix on, and talks to a wineserver that + # outlives the process; neither survives the sandbox. + sandbox = False, + test_cmd = test_cmd, + ) + +def _wine_run_cmd(binary:str, args:str, exit_code:int, batch:bool=False): + """Returns a command that runs a Windows binary under Wine and checks how it finished. + + The status is captured rather than left to the shell: build actions run with -e, so an exit + code we are expecting would otherwise end the command before we could check it. + + The pipe is not only for the log. Wine's console emulation gives a child process handles + that some programs - Python among them - reject at startup unless the output is a pipe, + which has nothing to do with whatever is being tested. + + A batch file is handed to cmd.exe explicitly, and it matters that it is. Handing one to + `wine` looks like it works and is not the same thing: what Wine cannot load as a PE it + passes to the host, so a file that still has a Unix shebang on it runs under /bin/sh and + produces exactly the output the test was hoping for. The whole point of the wrapper is + that Windows has no shebang mechanism, so a test that leaves this to Wine tests nothing. + """ + run = f'wine "{binary}" {args}' + if batch: + run = f'wine cmd /c "$(winepath -w "{binary}")" {args}' + return " && ".join([ + "code=0", + f'{run} 2>&1 | tee "$TMP_DIR/output" || code=$?', + f'[ "$code" = "{exit_code}" ]', + ]) + +def wine_binary_test( + name:str, + binary:str, + args:str="", + data:list=[], + expected_output:str="", + exit_code:int=0, + batch:bool=False, + needs_shell:bool=False, + runs:int=1, + labels:list=[], + timeout:int=600, + size:str=None): + """Runs a Windows binary under Wine and checks what it printed and how it exited. + + For things that are not tests themselves - a cc_binary, say - where the point is that the + program runs at all and produces the right answer. + + Args: + name (str): Name of the rule. + binary (str): The target to run, which must be in the windows_amd64 architecture - i.e. a + label of the form ///windows_amd64//pkg:it. + args (str): Arguments to pass to it. + data (list): Runtime data it needs. It lands beside the binary, which on Windows is also + how a DLL is found: there is no rpath, so it has to be in the same directory. + expected_output (str): Text its output should contain. Empty to check nothing. + exit_code (int): The exit code it should finish with. + batch (bool): True if it is a .cmd rather than a .exe, so that it is run through cmd.exe + rather than left to Wine, which would fall back to the host shell. + needs_shell (bool): True if running it involves a shell - an sh_binary's wrapper does. + Puts the bundled busybox on the Windows PATH, which is where such a wrapper + expects to find it. + runs (int): How many times to run it, all in the same directory. More than one is worth + asking for when it writes something it will meet again on the next run: a + read-only file left behind cannot be replaced on Windows at all. + labels (list): Extra labels for the rule. + timeout (int): Test timeout in seconds. + size (str): Test size. + """ + + cmds = [_wine_setup_cmd()] + test_data = {"BINARY": [binary], "FILES": data} + if needs_shell: + # Its own directory rather than the working one: this is how an install has it, and Go's + # exec refuses to run something found relative to the current directory anyway. + test_data["BUSYBOX"] = ["///windows_amd64//third_party/binary:busybox"] + cmds.append('mkdir -p "$TMP_DIR/shell" && cp "$DATA_BUSYBOX" "$TMP_DIR/shell/busybox.exe"') + cmds.append('export WINEPATH="$(winepath -w "$TMP_DIR/shell")"') + for _ in range(runs): + cmds.append(_wine_run_cmd("$DATA_BINARY", args, exit_code, batch = batch)) + if expected_output: + cmds.append(f'grep -q "{expected_output}" "$TMP_DIR/output"') + + return gentest( + name = name, + size = size, + timeout = timeout, + data = test_data, + env = WINE_ENV, + labels = labels + ["wine", "windows"], + local = True, + no_test_output = True, + sandbox = False, + test_cmd = " && ".join(cmds), + ) + +def wine_pex_test( + name:str, + pex:str, + args:str="", + data:list=[], + exit_code:int=0, + test_output:bool=True, + labels:list=[], + timeout:int=600, + size:str=None): + """Runs a .pex that was cross-compiled for Windows, under Wine. + + A .pex is not a script with a shebang, it is a zip with an executable stub in front of it, + and that stub has to be a Windows one. Running it here is therefore a real test of the + preamble please_pex prepended, not only of the Python inside. + + The interpreter is the embeddable Python from python.org, put on the Windows PATH rather + than installed - which is also what the preamble's default search for "python" expects to + find. + + Args: + name (str): Name of the rule. + pex (str): The python_test or python_binary target to run, which must be in the + windows_amd64 architecture - i.e. a label of the form ///windows_amd64//pkg:it. + args (str): Arguments to pass to it. + data (list): Runtime data it needs, at the path it expects to read it from. A pex's own + data doesn't come along when another rule depends on it. + exit_code (int): The exit code it should finish with. + test_output (bool): True if it is a python_test, and so writes JUnit XML that Please + should read. False for a python_binary, which writes none. + labels (list): Extra labels for the rule. + timeout (int): Test timeout in seconds. + size (str): Test size. + """ + + # Run it where it lands, under its own name. Unlike a go_test there is nothing to rename: + # the .pex is already called .pex.exe, which is the whole point. + return gentest( + name = name, + size = size, + timeout = timeout, + data = { + "PEX": [pex], + # WINEPATH is what Wine adds to the Windows PATH. python.exe is looked up on it by + # name, which is what a .pex does when nothing has configured an interpreter path. + "PYTHON": ["//third_party/binary:python-windows"], + "FILES": data, + }, + env = WINE_ENV, + # The test runner writes JUnit XML into a directory rather than a single file. + labels = labels + ["wine", "windows"] + (["test_results_dir"] if test_output else []), + local = True, + no_test_output = not test_output, + sandbox = False, + test_cmd = " && ".join([ + _wine_setup_cmd(), + 'export WINEPATH="$(winepath -w "$DATA_PYTHON")"', + _wine_run_cmd("$DATA_PEX", args, exit_code), + ]), + ) + +def wine_plz_test( + name:str, + repo:str, + cmd:str, + expected_output:dict={}, + labels:list=[], + expected_failure:bool=False, + timeout:int=600): + """Runs the cross-built please.exe under Wine against a small test repo. + + This is the counterpart of please_repo_e2e_test for Windows: it checks that Please + actually *runs* there, rather than merely that it compiles. + + Args: + name (str): Name of the rule. + repo (str): A directory containing a small Please repo to run in. + cmd (str): Arguments to pass to please.exe, e.g. 'build //:target'. + expected_output (dict): Maps a file the build should produce, relative to the repo root, + to a file in the repo holding the content it should have. + labels (list): Extra labels for the rule. + expected_failure (bool): True if the command is expected to exit non-zero. + timeout (int): Test timeout in seconds. + """ + + # The Windows release layout: please.exe with busybox.exe beside it, which is where the + # default [build] shell of 'busybox' gets resolved from. Nothing is put on the PATH, so + # this also covers the resolution the bundling depends on. + setup = [ + _wine_setup_cmd(), + 'mkdir -p "$TMP_DIR/plzdir"', + 'cp "$DATA_PLEASE" "$TMP_DIR/plzdir/please.exe"', + 'cp "$DATA_BUSYBOX" "$TMP_DIR/plzdir/busybox.exe"', + 'cp -r "$DATA_REPO" "$TMP_DIR/repo"', + 'cd "$TMP_DIR/repo"', + ] + run = f'wine "$TMP_DIR/plzdir/please.exe" {cmd} 2>&1 | tee "$TMP_DIR/output"' + if expected_failure: + # Please exits non-zero, so check that rather than letting the pipeline fail us. + run = f"if {run}; then exit 1; fi" + test_cmd = " && ".join(setup + [run] + [ + f'diff -u "{expected}" "{out}"' + for out, expected in expected_output.items() + ]) + return gentest( + name = name, + timeout = timeout, + data = { + "PLEASE": ["///windows_amd64//src:please"], + "BUSYBOX": ["///windows_amd64//third_party/binary:busybox"], + "REPO": [repo], + }, + env = WINE_ENV, + labels = labels + ["wine", "windows"], + local = True, + no_test_output = True, + sandbox = False, + test_cmd = test_cmd, + ) diff --git a/test/subrepo/nested_subrepo_probe/BUILD b/test/subrepo/nested_subrepo_probe/BUILD new file mode 100644 index 0000000000..836ed25988 --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/BUILD @@ -0,0 +1,19 @@ +subinclude("//test/build_defs") + +# Resolving a subrepo that is declared inside another subrepo makes Please parse the package it +# would be declared in in the host repo first, to see if it is there. When it isn't - which is the +# normal case, not an error - that lookup used to claim the right to parse the package and never +# give it back, so the next thing to ask about the same package waited on a parse nobody was going +# to do. The build hung with no output at all rather than failing. +# +# Two subrepos are needed to see it: the first one to look poisons the lookup, the second one +# waits on it. timeout is what makes this a failure rather than a hang. +please_repo_e2e_test( + name = "nested_subrepo_probe_test", + expected_output = { + "plz-out/gen/suba/a.txt": "one", + "plz-out/gen/subb/b.txt": "two", + }, + plz_command = "timeout 180 plz build //:both", + repo = "test_repo", +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/.plzconfig b/test/subrepo/nested_subrepo_probe/test_repo/.plzconfig new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/subrepo/nested_subrepo_probe/test_repo/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/BUILD_FILE new file mode 100644 index 0000000000..cfd97b2ea3 --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/BUILD_FILE @@ -0,0 +1,21 @@ +# Two subrepos, each of which reaches for a subrepo of its own that is declared in a package +# called "nested". This repo deliberately has no "nested" directory: resolving ///nested/one and +# ///nested/two makes Please look here first, find nothing, and fall back to the subrepo that +# asked. Whichever of the two looks here first has to leave the lookup usable for the other. +local_repository( + name = "suba", + path = "suba", +) + +local_repository( + name = "subb", + path = "subb", +) + +filegroup( + name = "both", + srcs = [ + "///suba//:t", + "///subb//:t", + ], +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/suba/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/suba/BUILD_FILE new file mode 100644 index 0000000000..b96828501d --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/suba/BUILD_FILE @@ -0,0 +1,7 @@ +genrule( + name = "t", + srcs = ["///nested/one//:f"], + outs = ["a.txt"], + cmd = "cp $SRCS $OUT", + visibility = ["PUBLIC"], +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/BUILD_FILE new file mode 100644 index 0000000000..450d24e6ec --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/BUILD_FILE @@ -0,0 +1,8 @@ +# Declared in a package called "nested", so the subrepo is registered as nested/one - which is +# what makes resolving it look for a package called "nested" in the host repo first. +# +# N.B. path is relative to the repo root rather than to suba, which is where this is declared. +local_repository( + name = "one", + path = "suba/nested/one", +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/one/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/one/BUILD_FILE new file mode 100644 index 0000000000..34cf706a48 --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/suba/nested/one/BUILD_FILE @@ -0,0 +1,6 @@ +genrule( + name = "f", + outs = ["f.txt"], + cmd = "echo one > $OUT", + visibility = ["PUBLIC"], +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/subb/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/subb/BUILD_FILE new file mode 100644 index 0000000000..00ecaff73b --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/subb/BUILD_FILE @@ -0,0 +1,7 @@ +genrule( + name = "t", + srcs = ["///nested/two//:f"], + outs = ["b.txt"], + cmd = "cp $SRCS $OUT", + visibility = ["PUBLIC"], +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/BUILD_FILE new file mode 100644 index 0000000000..0d431bc8a1 --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/BUILD_FILE @@ -0,0 +1,8 @@ +# Declared in a package called "nested", so the subrepo is registered as nested/two - which is +# what makes resolving it look for a package called "nested" in the host repo first. +# +# N.B. path is relative to the repo root rather than to subb, which is where this is declared. +local_repository( + name = "two", + path = "subb/nested/two", +) diff --git a/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/two/BUILD_FILE b/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/two/BUILD_FILE new file mode 100644 index 0000000000..efbe6bdd48 --- /dev/null +++ b/test/subrepo/nested_subrepo_probe/test_repo/subb/nested/two/BUILD_FILE @@ -0,0 +1,6 @@ +genrule( + name = "f", + outs = ["f.txt"], + cmd = "echo two > $OUT", + visibility = ["PUBLIC"], +) diff --git a/test/windows/BUILD b/test/windows/BUILD new file mode 100644 index 0000000000..a026c4e7d7 --- /dev/null +++ b/test/windows/BUILD @@ -0,0 +1,284 @@ +subinclude("//test/build_defs:wine", "//test/build_defs:windows_bundle") + +# The unit tests worth running on Windows are the ones the port actually changed: process +# management and locking, the filesystem layer, and config and path handling. Each entry is +# (name, cross-built test target, runtime data, whether it needs a shell). +# +# This list has one definition because it is consumed twice: wine_go_test runs each binary +# under Wine here, and windows_test_bundle packages the same binaries and the same data for a +# real Windows machine to run natively. See docs/design/windows/05-testing-strategy.md for what +# each of those is worth. +WINDOWS_GO_TESTS = [ + ( + "fs_test", + "///windows_amd64//src/fs:fs_test", + ["///windows_amd64//src/fs:test_data"], + False, + ), + ( + "core_test", + "///windows_amd64//src/core:core_test", + ["///windows_amd64//src/core:test_data"], + False, + ), + # Runs real build actions, so it exercises the process layer and the bundled shell as well + # as whatever it is nominally about. + ( + "build_test", + "///windows_amd64//src/build:build_test", + ["///windows_amd64//src/build:test_data"], + True, + ), + # Coverage parsing and the command-line layer: neither runs a build action, but both handle + # paths that came from somewhere else. + ( + "test_test", + "///windows_amd64//src/test:test_test", + ["///windows_amd64//src/test:test_data"], + False, + ), + ("cli_test", "///windows_amd64//src/cli:cli_test", [], False), + # Parsing is the valuable one: the BUILD language interpreter handles paths from every + # direction. + ("parse_test", "///windows_amd64//src/parse:parse_step_test", [], False), + ( + "asp_test", + "///windows_amd64//src/parse/asp:asp_test", + ["///windows_amd64//src/parse/asp:asp_test_data"], + False, + ), + ( + "query_test", + "///windows_amd64//src/query:query_test", + ["///windows_amd64//src/query:query_test_data"], + False, + ), + ( + "format_test", + "///windows_amd64//src/format:format_test", + ["///windows_amd64//src/format:format_test_data"], + False, + ), + ( + "export_test", + "///windows_amd64//src/export:export_test", + ["///windows_amd64//src/export:export_test_data"], + False, + ), + ("output_test", "///windows_amd64//src/output:output_test", [], False), + ( + "hashes_test", + "///windows_amd64//src/hashes:hash_rewriter_test", + ["///windows_amd64//src/hashes:hash_rewriter_test_data"], + False, + ), + ( + "gc_test", + "///windows_amd64//src/gc:gc_test", + ["///windows_amd64//src/gc:gc_test_data"], + False, + ), + ("tool_test", "///windows_amd64//src/tool:tool_test", [], False), + ("plz_test", "///windows_amd64//src/plz:plz_test", [], False), + ("clean_test", "///windows_amd64//src/clean:clean_test", [], False), + ( + "cache_test", + "///windows_amd64//src/cache:cache_test", + ["///windows_amd64//src/cache:cache_test_data"], + True, + ), + ("exec_test", "///windows_amd64//src/exec:exec_test", [], True), + ("process_test", "///windows_amd64//src/process:process_test", [], True), + ( + "update_test", + "///windows_amd64//src/update:update_test", + [ + "///windows_amd64//src/update:test_data", + "///windows_amd64//src/update:test_please", + "///windows_amd64//src/update:test_tarball", + "///windows_amd64//src:please", + ], + False, + ), + ( + "remote_test", + "///windows_amd64//src/remote:remote_test", + ["///windows_amd64//src/remote:remote_test_data"], + False, + ), + ( + "run_test", + "///windows_amd64//src/run:run_test", + ["///windows_amd64//src/run:run_test_data"], + False, + ), + # plz watch compares the paths it recorded against the ones fsnotify reports, which use + # different separators on Windows. A mismatch is silent - every event looks like it belongs + # to a file we aren't watching - so this only means anything when run on one. + ("watch_test", "///windows_amd64//src/watch:watch_test", [], False), +] + +[ + wine_go_test( + name = name, + data = data, + needs_shell = shell, + test = target, + ) + for name, target, data, shell in WINDOWS_GO_TESTS +] + +# The same binaries and the same data, packaged for a windows-latest runner to run natively. +# This is what turns "passes under Wine" into evidence from Windows. +windows_test_bundle( + name = "native_test_bundle", + tests = WINDOWS_GO_TESTS, +) + +# The shell smoke test: a real build action with a pipe and a redirect, run by the busybox +# that ships in the Windows release, found the way a user's install would find it. +wine_plz_test( + name = "shell_test", + cmd = "build //:pipeline", + expected_output = {"plz-out/gen/sorted.txt": "expected_sorted.txt"}, + repo = "smoke_repo", +) + +# A dropped forceposix build tag breaks every build label and is invisible in Please's own +# source, so assert label parsing works at all. +wine_plz_test( + name = "label_test", + cmd = "query alltargets //...", + repo = "smoke_repo", +) + +# A .pex is a zip with an executable stub in front of it, so on Windows it needs a Windows stub; +# the one please_pex prepends everywhere else is an ELF binary and will not run at all. This +# checks the whole chain - the preamble runs, finds an interpreter, and Python imports the test +# out of the zip - which is the only way any of it is exercised before a Windows machine exists. +wine_pex_test( + name = "pex_test", + data = ["//test/windows/python:data"], + pex = "///windows_amd64//test/windows/python:pex_test", +) + +# The preamble runs the interpreter as a child rather than replacing itself with it, because +# Windows has nothing to replace itself with. That makes passing the exit code back its job. +wine_pex_test( + name = "pex_exit_code_test", + args = "7", + exit_code = 7, + pex = "///windows_amd64//test/windows/python:exit_code", + test_output = False, +) + +# Windows resolves a DLL's symbols through an import library rather than through the DLL, so +# linking against a cc_shared_object needs one to exist. This builds the pair, links one against +# the other, and runs it - which also covers the DLL being found at run time, where Windows has +# no rpath and looks beside the binary instead. +# cc_test was recorded as blocked on Windows because UnitTest++ supposedly needs Win32 +# sources the plugin does not include. It does include them. What actually blocked it was +# that the plugin's own targets - the UnitTest++ test main is a cc_library here - compiled +# with the host toolchain whatever the using repo configured, so the Win32 sources were +# handed to a compiler with no windows.h. +# +# The runtime DLLs travel beside it; see //test/windows/cc:mingw_runtime for why they +# cannot simply be linked in. +wine_binary_test( + name = "cc_test_test", + binary = "///windows_amd64//test/windows/cc:greeting_test", + data = [ + "///windows_amd64//test/windows/cc:greeting", + "///windows_amd64//test/windows/cc:mingw_runtime", + ], + # Exit code rather than output: UnitTest++ writes its results as XML to test.results + # and prints nothing when everything passes. It returns the number of failures, so zero + # is the assertion - and a binary that could not start at all exits 53, not 0. +) + +wine_binary_test( + name = "dll_test", + binary = "///windows_amd64//test/windows/cc:hello", + data = ["///windows_amd64//test/windows/cc:greeting"], + expected_output = "hello from a dll", +) + +# Windows has no shebang mechanism, so an sh_binary comes out as a .cmd there: a batch preamble +# that unpacks the zip appended to it and hands the script to the bundled busybox. This runs one +# the way a user would, and covers all three things the preamble has to get right - the payload +# is unpacked, arguments reach the script, and its exit status comes back out. +wine_binary_test( + name = "sh_binary_test", + args = "world 3", + batch = True, + binary = "///windows_amd64//test/windows/shell:greet", + exit_code = 3, + expected_output = "hello from world", + needs_shell = True, + # Twice, in the one directory. The payload it unpacks is a set of build outputs, which + # are read-only, and a read-only file on Windows cannot be replaced at all - so the + # second run is where a stale payload would go unnoticed. + runs = 2, +) + +# Everything the release zip should contain, and nothing else. None of these names needs +# quoting in a shell word, which is what lets them go straight into the printf below. +RELEASE_MEMBERS = [ + "please", + "please/Install.md", + "please/arcat.exe", + "please/build_langserver.exe", + "please/busybox.exe", + "please/please.exe", + "please/plz.cmd", +] + +# The shape of the artifact, checked on Linux without Wine. It catches the things that would +# otherwise only show up on a real Windows machine: a tool that lost its .exe and so cannot be +# run at all, or a file quietly dropped from the release. +# +# Labelled wine like the rest of this package, not because anything here runs under Wine but +# because building it means cross-compiling for Windows, which is what that label gates. +gentest( + name = "release_shape_test", + data = {"ZIP": ["///windows_amd64//package:please_zip"]}, + labels = [ + "wine", + "windows", + ], + no_test_output = True, + test_cmd = " && ".join([ + # $DATA_ZIP is relative to the test directory, and unzip is about to leave it. + 'zip="$PWD/$DATA_ZIP"', + 'unzip -Z1 "$zip" | sed "s|/$||" | sort > "$TMP_DIR/got"', + # Both sides sorted by the same sort, so the list above can stay in a readable order + # rather than whatever collation this machine happens to use. + 'printf "%s\\n" ' + " ".join(RELEASE_MEMBERS) + ' | sort > "$TMP_DIR/want"', + 'diff -u "$TMP_DIR/want" "$TMP_DIR/got"', + ]), +) + +# The decisions about what the codelabs mean, and what is known to fail when they run on Windows. +# Read by //test/windows/codelab_script's tests on Linux, and by run_codelabs.ps1 on Windows. +filegroup( + name = "codelab_metadata", + srcs = [ + "codelab_known_failures.txt", + "codelab_steps.conf", + ], + visibility = ["//test/windows/..."], +) + +# The plan run_codelabs.ps1 replays. Built on Linux and handed to the Windows job as an artifact, +# so the Windows machine consumes something these tests have already checked. Deliberately not +# labelled wine: building it cross-compiles nothing. +genrule( + name = "codelab_plan", + srcs = { + "CODELABS": ["//docs/codelabs:codelab_md"], + "SIDECAR": ["codelab_steps.conf"], + }, + outs = ["codelab_plan.json"], + cmd = "$TOOLS --sidecar $SRCS_SIDECAR --out $OUT $SRCS_CODELABS", + tools = ["//test/windows/codelab_script"], +) diff --git a/test/windows/cc/BUILD b/test/windows/cc/BUILD new file mode 100644 index 0000000000..f1179c8ebf --- /dev/null +++ b/test/windows/cc/BUILD @@ -0,0 +1,68 @@ +# The subject of //test/windows:dll_test. Built for windows_amd64 and run under Wine; nothing +# builds these here, hence the manual labels. +subinclude("///cc//build_defs:cc") + +cc_shared_object( + name = "greeting", + srcs = ["greeting.cpp"], + hdrs = ["greeting.h"], + labels = ["manual"], + visibility = ["//test/windows:all"], +) + +# -lgreeting resolves against the import library the shared object emits alongside the DLL, +# which is the part that did not exist before. The DLL itself is a run-time dependency: Windows +# has no rpath, so it has to be beside the binary, which is what the test rule arranges. +cc_binary( + name = "hello", + srcs = ["main.cpp"], + hdrs = ["greeting.h"], + labels = ["manual"], + linker_flags = [ + "-L" + package_name(), + "-lgreeting", + ], + visibility = ["//test/windows:all"], + deps = [":greeting"], +) + +# The subject of //test/windows:cc_test_test. cc_test was recorded as blocked on Windows, +# because UnitTest++ supposedly needs Win32 sources the plugin does not include - which is not +# true, upstream selects them. This is here to find out what, if anything, is actually wrong. +cc_test( + name = "greeting_test", + srcs = ["greeting_test.cpp"], + hdrs = ["greeting.h"], + labels = ["manual"], + linker_flags = [ + "-L" + package_name(), + "-lgreeting", + ], + visibility = ["//test/windows:all"], + deps = [":greeting"], +) + +# The MinGW C++ runtime, which a cross-built binary needs beside it unless it was linked +# statically. The cc plugin wraps linker_flags in -Wl, so -static-libstdc++ cannot be passed +# through it - those are driver flags, and ld rejects them - and target compiler_flags reach the +# compile step but not the link. So the DLLs travel with the test, the same way the greeting DLL +# does. A cc_binary doing less C++ gets away without them, which is why the DLL test never +# needed this. +# +# Taken from the toolchain rather than pinned: they have to match the compiler that built the +# binary, and -print-file-name is how the compiler says where its own runtime is. +genrule( + name = "mingw_runtime", + outs = [ + "libgcc_s_seh-1.dll", + "libstdc++-6.dll", + ], + binary = True, + cmd = 'for dll in $OUTS; do cp "$($TOOL -print-file-name=$(basename $dll))" "$dll"; done', + labels = ["manual"], + test_only = True, + # The plugin decides this per platform now and leaves the config key unset, so name the + # cross compiler directly, falling back to whatever the repo configured if it did. + tools = [CONFIG.CC.CPP_TOOL or "x86_64-w64-mingw32-g++"], + visibility = ["//test/windows:all"], +) diff --git a/test/windows/cc/greeting.cpp b/test/windows/cc/greeting.cpp new file mode 100644 index 0000000000..5206622208 --- /dev/null +++ b/test/windows/cc/greeting.cpp @@ -0,0 +1,5 @@ +#include "test/windows/cc/greeting.h" + +const char *greeting() { + return "hello from a dll"; +} diff --git a/test/windows/cc/greeting.h b/test/windows/cc/greeting.h new file mode 100644 index 0000000000..64b9ed65dd --- /dev/null +++ b/test/windows/cc/greeting.h @@ -0,0 +1,4 @@ +// Deliberately not marked with __declspec(dllexport): MinGW exports every symbol from a DLL +// that declares none explicitly, and a rule that only works with source annotations would be a +// worse test of the rule. +const char *greeting(); diff --git a/test/windows/cc/greeting_test.cpp b/test/windows/cc/greeting_test.cpp new file mode 100644 index 0000000000..6e997934cb --- /dev/null +++ b/test/windows/cc/greeting_test.cpp @@ -0,0 +1,15 @@ +// The subject of //test/windows:cc_test_test. Built for windows_amd64 and run under Wine; +// nothing builds this here. +// +// It exists to find out whether cc_test works on Windows at all. The recorded blocker was that +// UnitTest++ needs its Win32 sources and the plugin does not include them, which is not true - +// upstream has selected them on Windows since before this port started. +#include + +#include + +#include "test/windows/cc/greeting.h" + +TEST(GreetingIsWhatTheLibrarySays) { + CHECK(std::strcmp(greeting(), "hello from a dll") == 0); +} diff --git a/test/windows/cc/main.cpp b/test/windows/cc/main.cpp new file mode 100644 index 0000000000..753c4e9354 --- /dev/null +++ b/test/windows/cc/main.cpp @@ -0,0 +1,8 @@ +#include + +#include "test/windows/cc/greeting.h" + +int main() { + printf("%s\n", greeting()); + return 0; +} diff --git a/test/windows/codelab_known_failures.txt b/test/windows/codelab_known_failures.txt new file mode 100644 index 0000000000..1c3f3449ae --- /dev/null +++ b/test/windows/codelab_known_failures.txt @@ -0,0 +1,57 @@ +# Codelab steps known to fail on Windows. One "codelab_id" or "codelab_id::step-key" per line, +# with a comment above saying why. Step keys are printed by run_codelabs.ps1 and by +# //test/windows:codelab_plan. +# +# A step listed here that starts passing fails the job too, so this list only ever shrinks. An +# entry naming a step that no longer exists fails on Linux, in +# //test/windows/codelab_script/script:script_test. +# +# This file records what the codelabs do on Windows. It is not a list of things to fix in them: +# no codelab has been edited to make anything here pass. +# +# Every entry rests either on a fact checked directly, or on what a native run printed, quoted in +# its reason. Nothing is guessed at: a wrong guess fails the job exactly as a missing entry does. + +# plz init plugin go writes plugin_repo(owner = "please-build") (src/plzinit/plugins.go), and the +# upstream go-rules releases publish please_go for darwin, freebsd and linux only, with no +# windows_amd64 asset. This repo's own plugins/BUILD pins the PeterNeiss forks for exactly that +# reason. Following the codelab as written, the first build of a Go target cannot succeed on +# Windows, and everything after it in the codelab is blocked behind it. The codelab's toolchain +# step also pins go_toolchain to 1.20, for which the plugin requests go1.20.windows-amd64.tar.gz; +# golang.org publishes Windows releases only as zips, and the first native run got a 404 for it. +# Ahead of both, and on every platform: the codelab's third_party/go/BUILD holds only that +# go_toolchain, so writing it replaces the go_stdlib plz init plugin go generated, and the second +# native run reported //third_party/go:std missing. +go_intro::hello-world/b3.1 + +# The same for Python: plz init plugin python gets upstream python-rules, whose please_pex has no +# windows_amd64 release. On top of that, the default [build] path on Windows is empty, and the +# codelab's only advice for adding an interpreter to it is a colon-separated Unix template. +python_intro::hello-world/b3.1 + +# An inline environment prefix, GODEBUG="installgoroot=all" go install std, is bash syntax. +# PowerShell reads the assignment as the name of a command. The codelab gives no Windows form. +puku::initialising-your-project-and-running-puku-with-please/b12.1 + +# plz puku runs //third_party/binary:puku, a remote_file of puku--_, and puku +# publishes no windows_amd64 release. Every later step of the codelab depends on it. +puku::adding-and-updating-modules/b3.1 + +# plz run //tools:wc runs tools/wc.sh, a filegroup with binary = True holding a #!/bin/bash script. +# Windows runs nothing by its shebang. The first native run failed with "%1 is not a valid Win32 +# application", and Please's own message says the file needs an extension Windows will run. The +# rest of the codelab builds on this tool, so it is blocked behind it. +genrule::managing-tools/b4.1 + +# The codelab clones thought-machine/please-codelabs, whose getting_started_go uses the upstream Go +# plugin. On the first native run its tools/BUILD had no please_go target, there being no +# windows_amd64 release, and its go_toolchain requested go1.20.windows-amd64.tar.gz, a 404. The +# first plz query cannot resolve the graph, and the rest of the codelab is blocked behind it. +plz_query::finding-dependencies-of-a-target/b1.1 + +# Not a Windows failure. plz init plugin go generates third_party/go/BUILD with a go_toolchain and a +# go_stdlib, and points STDLib at the stdlib. The codelab's "Add a go toolchain" block holds only a +# go_toolchain: written as the file it replaces the go_stdlib, and appended it would define a second +# target named toolchain. The second native run stopped here with //third_party/go:std missing. On +# Windows the upstream please_go and the Go 1.20 .tar.gz would fail next, as they do in go_intro. +k8s::using-our-base-image/b3.1 diff --git a/test/windows/codelab_script/BUILD b/test/windows/codelab_script/BUILD new file mode 100644 index 0000000000..0307c53507 --- /dev/null +++ b/test/windows/codelab_script/BUILD @@ -0,0 +1,11 @@ +# Reduces the published codelabs to a plan that test/windows/run_codelabs.ps1 replays on a real +# Windows machine. See script/parse.go for why it refuses to guess. +go_binary( + name = "codelab_script", + srcs = ["main.go"], + visibility = ["//test/windows/..."], + deps = [ + "///third_party/go/github.com_peterebden_go-cli-init_v5//flags", + "//test/windows/codelab_script/script", + ], +) diff --git a/test/windows/codelab_script/main.go b/test/windows/codelab_script/main.go new file mode 100644 index 0000000000..4dd911fea5 --- /dev/null +++ b/test/windows/codelab_script/main.go @@ -0,0 +1,73 @@ +// Command codelab_script reduces the codelabs to a plan test/windows/run_codelabs.ps1 can replay. +// See the script package for why it is built the way it is. +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/peterebden/go-cli-init/v5/flags" + + "github.com/thought-machine/please/test/windows/codelab_script/script" +) + +var opts = struct { + Sidecar string `long:"sidecar" required:"true" description:"codelab_steps.conf"` + Format string `long:"format" default:"plan" choice:"plan" choice:"summary" description:"plan emits the JSON the runner reads; summary prints one line per block, for authoring the sidecar"` + Out string `short:"o" long:"out" description:"File to write to; defaults to stdout"` + Args struct { + Codelabs []string `positional-arg-name:"codelabs" required:"true" description:"The codelab .md files"` + } `positional-args:"true" required:"true"` +}{} + +func main() { + flags.ParseFlagsOrDie("Codelab script", &opts, nil) + + b, err := os.ReadFile(opts.Sidecar) + if err != nil { + die("%s", err) + } + side, err := script.ParseSidecar(opts.Sidecar, string(b)) + if err != nil { + die("%s", err) + } + var codelabs []script.Codelab + for _, filename := range opts.Args.Codelabs { + b, err := os.ReadFile(filename) + if err != nil { + die("%s", err) + } + codelabs = append(codelabs, script.ParseCodelab(filename, string(b))) + } + + var out []byte + if opts.Format == "summary" { + // Deliberately tolerant: this is how the sidecar gets written, so it has to print + // the blocks nothing has decided yet instead of stopping at the first one. + out = []byte(script.Census(codelabs, side)) + } else { + plan, errs := script.BuildPlan(codelabs, side) + if len(errs) > 0 { + for _, err := range errs { + fmt.Fprintf(os.Stderr, "%s\n\n", err) + } + die("%d problem(s) extracting the codelabs; nothing was written", len(errs)) + } + if out, err = json.MarshalIndent(plan, "", " "); err != nil { + die("%s", err) + } + out = append(out, '\n') + } + + if opts.Out == "" { + os.Stdout.Write(out) + } else if err := os.WriteFile(opts.Out, out, 0644); err != nil { + die("%s", err) + } +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/test/windows/codelab_script/script/BUILD b/test/windows/codelab_script/script/BUILD new file mode 100644 index 0000000000..9b4c2c89e4 --- /dev/null +++ b/test/windows/codelab_script/script/BUILD @@ -0,0 +1,27 @@ +go_library( + name = "script", + srcs = [ + "classify.go", + "parse.go", + "plan.go", + "sidecar.go", + ], + visibility = ["//test/windows/..."], +) + +# The drift guard: runs against the real codelabs and the real sidecar, so a codelab edit that +# leaves a block undecided fails here, on Linux, in the default test pass. +go_test( + name = "script_test", + srcs = ["script_test.go"], + data = [ + "test_data", + "//docs/codelabs:codelab_md", + "//test/windows:codelab_metadata", + ], + deps = [ + ":script", + "///third_party/go/github.com_stretchr_testify//assert", + "///third_party/go/github.com_stretchr_testify//require", + ], +) diff --git a/test/windows/codelab_script/script/classify.go b/test/windows/codelab_script/script/classify.go new file mode 100644 index 0000000000..37ee9da0ac --- /dev/null +++ b/test/windows/codelab_script/script/classify.go @@ -0,0 +1,222 @@ +package script + +import ( + "fmt" + "regexp" + "strings" +) + +// Kind is what a fenced block turned out to be. +type Kind string + +const ( + // A file to write, with a path and contents. + KindFile Kind = "file" + // Commands to run. + KindCommand Kind = "command" + // Commands shown with the output they produced. Runnable, but the output is what the + // codelab saw on its author's machine and is advisory here; see plan.go. + KindTranscript Kind = "transcript" + // Shown for reference and never run: a `tree -a` listing, an expected build result, the + // YAML of a CI workflow, a walk-through of an interactive session. + KindIllustration Kind = "illustration" + // The sidecar says to leave this block alone entirely, with a reason. + KindIgnore Kind = "ignore" + // No rule decided. Always an error; see Classify. + KindUnclassified Kind = "unclassified" +) + +// A heading whose entire text is a backticked path, e.g. "### `src/BUILD`". The dominant +// convention: genrule, go_intro, python_intro and using_plugins introduce every file this way. +var fileHeadingRe = regexp.MustCompile("^#{2,6}\\s+`([^`]+)`\\s*$") + +// The same thing said in prose and ending in a colon, which is how puku and k8s do it throughout: +// "Create a file `hello_service/service.go`:". Those two use no file headings at all. +var fileProseRe = regexp.MustCompile("`([^`]+)`[^`]*:\\s*$") + +// The name half of an inline environment assignment. +var envPrefixRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + +// A transcript line: the command follows the prompt, and the rest of the block is its output. +var promptRe = regexp.MustCompile(`^\$\s+(.*)$`) + +// An introducing line promising output rather than asking for anything to be run. Several codelabs +// tag such a block ```bash anyway - using_plugins shows two `tree -a` listings that way, k8s a +// job-control trace - and running those lines would fail on every platform, Linux included. +var outputIntroRe = regexp.MustCompile(`(?i)\b(output|should look like|should see|will see|prints|looks like|similar to)\b`) + +// Fence languages that mean "this is the content of a file", given a path can be found for it. +var fileLangs = map[string]bool{ + "python": true, "go": true, "golang": true, "yaml": true, + "ini": true, "shell script": true, "dockerfile": true, +} + +// Fence languages that might hold commands. +var commandLangs = map[string]bool{"bash": true, "sh": true, "shell": true, "text": true, "": true} + +// The first word of a line that is plausibly a command. Deliberately a list rather than a pattern: +// the codelabs' output blocks are full of lines that look like commands to a pattern, and a wrong +// guess here becomes a step that fails on every platform for reasons that have nothing to do with +// Windows. Anything not listed is unclassified, which asks rather than guesses. +var commandVerbs = map[string]bool{ + "plz": true, "./pleasew": true, "pleasew": true, "./plz": true, + "go": true, "git": true, "puku": true, "pip": true, "pip3": true, + "python": true, "python3": true, "docker": true, "kubectl": true, "minikube": true, + "mkdir": true, "cd": true, "echo": true, "cat": true, "ls": true, "rm": true, + "touch": true, "cp": true, "mv": true, "curl": true, "wget": true, "tree": true, + "which": true, "pkill": true, "eval": true, "export": true, "source": true, + "printf": true, "wc": true, "sort": true, "sed": true, "grep": true, "chmod": true, +} + +// Classify works out what a block is, consulting the sidecar first. +// +// The order matters and each step earns its place: +// +// 1. The sidecar, which overrides everything and always carries a reason. +// 2. A heading immediately above whose whole text is a backticked path: a file. +// 3. A command-shaped fence, checked before the prose rule because a sentence naming a file is +// as often followed by the command that creates it as by its contents. +// 4. A file-shaped fence with a path findable in the prose before or after it. +// 5. Nothing: KindUnclassified, which the caller must treat as fatal. +// +// Returning KindUnclassified rather than quietly bucketing into "other" is the whole design. It is +// what makes a codelab edit that introduces an unreadable block go red on Linux, in an ordinary +// unit test, instead of silently shrinking what the Windows job checks. +func Classify(c Codelab, b Block, key string, side *Sidecar) (Kind, string, error) { + if entry, ok := side.Lookup(key); ok { + kind, path, err := entry.Kind(b) + if err != nil { + return KindUnclassified, "", fmt.Errorf("%s:%d: %s: %w", c.Source, b.Line, key, err) + } + if kind != "" { + return kind, path, nil + } + } + + if m := fileHeadingRe.FindStringSubmatch(b.Intro); m != nil { + return KindFile, m[1], nil + } + + if commandLangs[b.Lang] { + if hasPrompt(b.Body) { + return KindTranscript, "", nil + } + if outputIntroRe.MatchString(b.Intro) { + return KindIllustration, "", nil + } + if isCommandish(b.Body) { + return KindCommand, "", nil + } + } + + // Only the prose before a block is read for a path. The line after was tried and named the + // wrong file on its first outing: puku's "Add a filegroup for go.mod at `BUILD`:" is + // followed by "Update your `.plzconfig`:", which introduces the next block, not this one. + if fileLangs[b.Lang] || commandLangs[b.Lang] { + if path, ok := proseFilePath(b.Intro); ok { + return KindFile, path, nil + } + } + + return KindUnclassified, "", nil +} + +// Key is the identifier a step is known by, in codelab_steps.conf, in +// codelab_known_failures.txt and in the runner's report. +// +// "::/b", with the command's position appended for a block holding +// several. Readable rather than hashed, because the failures file is a findings record someone +// has to read; an edit in one section does not renumber another, which a whole-file ordinal could +// not promise, and codelab_steps.conf pins the text of what it names so an edit within a section +// cannot silently move a decision onto a different block. +func Key(c Codelab, b Block) string { + return fmt.Sprintf("%s::%s/b%d", c.ID, b.SectionSlug, b.Ordinal) +} + +func hasPrompt(body []string) bool { + for _, line := range body { + if promptRe.MatchString(line) { + return true + } + } + return false +} + +// isCommandish says whether the first non-blank line of a block starts with a word we recognise as +// a command, or with an inline environment assignment such as GODEBUG="installgoroot=all". +func isCommandish(body []string) bool { + for _, line := range body { + line = strings.TrimSpace(line) + if line == "" { + continue + } + first, _, _ := strings.Cut(line, " ") + if commandVerbs[first] { + return true + } + // An inline environment prefix: VAR=value cmd. Bash syntax, so these are exactly the + // steps most likely to fail on Windows - but they are commands, and saying so is what + // lets them run and be reported rather than sit unreadable. The value may be quoted, + // as in GODEBUG="installgoroot=all", so only the name is checked. + return envPrefixRe.MatchString(first) + } + return false +} + +// Commands splits a block into the commands to run and, for each, the output shown after it. +// +// In a transcript the output belongs to the command above it, not to the block. genrule shows +// `$ plz build` with its build summary and then `$ cat` with a word count; attaching both to the +// last command made the first native run report that none of the cat's lines appeared. +func Commands(b Block) (commands []string, expect [][]string) { + prompted := hasPrompt(b.Body) + for _, line := range b.Body { + if strings.TrimSpace(line) == "" { + continue + } + if !prompted { + commands = append(commands, line) + expect = append(expect, nil) + continue + } + if m := promptRe.FindStringSubmatch(line); m != nil { + commands = append(commands, m[1]) + expect = append(expect, nil) + } else if len(expect) > 0 { + expect[len(expect)-1] = append(expect[len(expect)-1], line) + } + } + return commands, expect +} + +// proseFilePath pulls a file path out of a sentence such as +// "Add the following to `common/docker/BUILD`:". +func proseFilePath(line string) (string, bool) { + m := fileProseRe.FindStringSubmatch(line) + if m == nil || !isRepoPath(m[1]) { + return "", false + } + return m[1], true +} + +// isRepoPath says whether a backticked token is plausibly a file in the repo being built. +// +// The exclusions are not hypothetical. Each one is a line in the codelabs as they stand that would +// otherwise be read as a file to create: +// - a space or a URL scheme: prose, or a link +// - a leading slash: a [build] path value, such as `/usr/local/go/bin/go` in puku +// - a dot in the first segment: a module path, such as `github.com/stretchr/testify` in go_intro +func isRepoPath(s string) bool { + if s == "" || strings.ContainsAny(s, " \t") || strings.Contains(s, "://") { + return false + } + if strings.HasPrefix(s, "/") { + return false + } + first, _, _ := strings.Cut(s, "/") + if strings.Contains(first, ".") && !strings.HasPrefix(first, ".") { + return false + } + // A bare BUILD is the one file name without a slash or a dot that the codelabs create. + return s == "BUILD" || strings.Contains(s, "/") || strings.Contains(s, ".") +} diff --git a/test/windows/codelab_script/script/parse.go b/test/windows/codelab_script/script/parse.go new file mode 100644 index 0000000000..29d9331268 --- /dev/null +++ b/test/windows/codelab_script/script/parse.go @@ -0,0 +1,142 @@ +// Package script turns the published codelabs into something a machine can replay. +// +// The codelabs at https://please.build/codelabs.html are the front door for new users, and nothing +// anywhere has ever executed a line of them. This package reads docs/codelabs/*.md and produces an +// ordered plan of the files each one tells you to create and the commands it tells you to run; +// test/windows/run_codelabs.ps1 replays that plan on a real Windows machine. +// +// The plan is derived from the Markdown rather than transcribed into fixtures, so that the check +// and the published page cannot drift apart. That means living with conventions the codelabs were +// never written to satisfy. Where a convention runs out, the answer is never to guess: an +// unclassified block is a hard error, and the decision gets written down in codelab_steps.conf +// with a reason. A tolerant parser that silently ignored what it could not read would emit a +// three-step plan for a thirty-step codelab, pass, and tell nobody anything. +// +// Parsed line by line with no Markdown library, like docs/codelabs/codelab_template.go, which +// reads the same front matter for the index page. A line scanner gives exact line numbers, which +// is what the error messages here are made of. +package script + +import ( + "regexp" + "strings" +) + +// Codelab is one .md file: its front matter and every fenced block in it. +type Codelab struct { + ID string + Title string + Status string + // Path as given, so a failure can be traced back to a file. + Source string + Blocks []Block +} + +// Block is one fenced block, with the context needed to work out what it is. +type Block struct { + // The fence's language tag, empty for a bare ```. + Lang string + Body []string + // 1-based line of the opening fence. + Line int + // Nearest non-blank line above the fence: a heading or a sentence that says what the block + // is, as in "Add the following to `common/docker/BUILD`:". + Intro string + // Text of the enclosing "##" heading, and its slug. Blocks are keyed by section rather than + // by position in the file so that an edit in one section does not renumber another. + Section string + SectionSlug string + // Ordinal of this block within its section, from 1. + Ordinal int +} + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})\s+(.*?)\s*$`) + nonSlugRe = regexp.MustCompile(`[^a-z0-9]+`) + frontKeyRe = regexp.MustCompile(`^([A-Za-z ]+):\s*(.*)$`) +) + +// ParseCodelab reads one codelab into its blocks. It does not classify them; see Classify. +func ParseCodelab(filename, content string) Codelab { + lines := strings.Split(content, "\n") + codelab := Codelab{Source: filename} + readFrontMatter(&codelab, lines) + + section, slug := "", "" + ordinal := 0 + + for i := 0; i < len(lines); i++ { + if m := headingRe.FindStringSubmatch(lines[i]); m != nil { + // Only "##" starts a new section. The codelabs use "###" for sub-steps and for + // file headings, both of which belong to the section around them. + if len(m[1]) == 2 { + section = m[2] + slug = slugify(m[2]) + ordinal = 0 + } + continue + } + if !strings.HasPrefix(lines[i], "```") { + continue + } + // An unterminated fence takes the rest of the file. The codelabs have none, but a + // half-written one should say so rather than silently swallowing every block after it. + end := i + 1 + for end < len(lines) && !strings.HasPrefix(lines[end], "```") { + end++ + } + ordinal++ + codelab.Blocks = append(codelab.Blocks, Block{ + Lang: strings.TrimSpace(strings.TrimPrefix(lines[i], "```")), + Body: lines[i+1 : end], + Line: i + 1, + Intro: nearestNonBlank(lines, i, -1), + Section: section, + SectionSlug: slug, + Ordinal: ordinal, + }) + i = end + } + return codelab +} + +// readFrontMatter reads the "key: value" header the codelabs open with, which runs until the first +// blank line. The same shape codelab_template.go reads, and only the fields this needs. +func readFrontMatter(codelab *Codelab, lines []string) { + for _, line := range lines { + if strings.TrimSpace(line) == "" { + return + } + m := frontKeyRe.FindStringSubmatch(line) + if m == nil { + continue + } + switch strings.ToLower(strings.TrimSpace(m[1])) { + case "id": + codelab.ID = strings.TrimSpace(m[2]) + case "summary": + codelab.Title = strings.TrimSpace(m[2]) + case "status": + codelab.Status = strings.TrimSpace(m[2]) + } + } +} + +// nearestNonBlank walks from i in the given direction and returns the first non-blank line, or "" +// if there is none. Blank lines are skipped and nothing else is. +func nearestNonBlank(lines []string, i, step int) string { + for j := i + step; j >= 0 && j < len(lines); j += step { + if strings.TrimSpace(lines[j]) != "" { + return lines[j] + } + } + return "" +} + +// slugify turns a heading into the section part of a step key: lowercase, words joined by +// hyphens. Readable, because these keys end up in codelab_known_failures.txt, which is a findings +// record someone has to read. +func slugify(s string) string { + s = nonSlugRe.ReplaceAllString(strings.ToLower(s), "-") + return strings.Trim(s, "-") +} diff --git a/test/windows/codelab_script/script/plan.go b/test/windows/codelab_script/script/plan.go new file mode 100644 index 0000000000..d465d10da5 --- /dev/null +++ b/test/windows/codelab_script/script/plan.go @@ -0,0 +1,355 @@ +package script + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// Plan is what the runner consumes: every codelab reduced to an ordered list of steps. +type Plan struct { + Codelabs []PlannedCodelab `json:"codelabs"` +} + +// PlannedCodelab is one codelab's steps, plus a census of what its blocks turned out to be. +type PlannedCodelab struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + // Set when the codelab has nothing anyone runs locally. github_actions teaches CI + // configuration and is entirely YAML. + NotRunnable string `json:"not_runnable,omitempty"` + // How many blocks there were and what they were. The report reconciles against this: a + // summary that counts only what it ran cannot tell you it ran almost nothing. + Blocks map[string]int `json:"blocks"` + Steps []Step `json:"steps"` +} + +// Step is one thing for the runner to do. +type Step struct { + Key string `json:"key"` + // "run", "file", "chdir" or "skip". + Kind string `json:"kind"` + Line int `json:"line"` + Section string `json:"section,omitempty"` + + // kind=run. + Command string `json:"command,omitempty"` + // What the codelab shows this command printing. Advisory: the corpus is full of timings, + // incrementality percentages and a randomly chosen greeting, so asserting on it would + // produce flakes that discredit the whole check. Promoted to a requirement only by the + // sidecar's assert directive. + ExpectedOutput []string `json:"expected_output,omitempty"` + Assert string `json:"assert,omitempty"` + // Tools this step needs, checked on the machine at run time. + Needs []string `json:"needs,omitempty"` + Timeout int `json:"timeout,omitempty"` + // A failure here does not mark the rest of the codelab blocked. True for a command that + // only displays something, and wherever the sidecar says so. + NonBlocking bool `json:"non_blocking,omitempty"` + + // kind=file. + Path string `json:"path,omitempty"` + Mode string `json:"mode,omitempty"` + Content string `json:"content,omitempty"` + + // kind=chdir. + Dir string `json:"dir,omitempty"` + + // kind=skip. + Reason string `json:"reason,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// A command line that only changes directory. The runner owns the working directory across steps, +// because a `cd` in a child process is lost the moment it exits - and the codelabs open with +// "mkdir getting_started_go && cd getting_started_go", with every later step depending on it. +var chdirRe = regexp.MustCompile(`^cd\s+([^\s;|&]+)\s*$`) + +// BuildPlan classifies every block of every codelab and returns the plan. +// +// It fails rather than guessing. An unclassified block, a sidecar stanza whose text no longer +// matches the block it names, and a stanza that names nothing at all are all errors, reported +// together so that one pass over the output fixes all of them. +func BuildPlan(codelabs []Codelab, side *Sidecar) (*Plan, []error) { + plan := &Plan{Codelabs: []PlannedCodelab{}} + var errs []error + + for _, c := range codelabs { + planned := PlannedCodelab{ + ID: c.ID, + Title: c.Title, + Source: c.Source, + Blocks: map[string]int{}, + Steps: []Step{}, + } + if entry, ok := side.Lookup(c.ID); ok { + planned.NotRunnable = entry.NotRunnable + } + + for _, b := range c.Blocks { + key := Key(c, b) + kind, path, err := decide(c, b, key, side, planned.NotRunnable != "") + if err != nil { + errs = append(errs, err) + continue + } + planned.Blocks["total"]++ + planned.Blocks[string(kind)]++ + + entry, hasEntry := side.Lookup(key) + if hasEntry { + if err := checkMatches(c, b, entry, kind, path); err != nil { + errs = append(errs, err) + continue + } + } + + switch kind { + case KindUnclassified: + errs = append(errs, unclassifiedError(c, b, key)) + case KindIllustration, KindIgnore: + // Carried in the census and nowhere else. Not a step, so it never + // appears in the pass, fail or skip tallies. + case KindFile: + planned.Steps = append(planned.Steps, fileStep(b, key, path, entry)) + case KindCommand, KindTranscript: + if hasEntry && entry.Skip != "" { + planned.Steps = append(planned.Steps, Step{ + Key: key, Kind: "skip", Line: b.Line, Section: b.Section, + Reason: entry.Skip, Detail: entry.Reason, + }) + continue + } + steps, stepErrs := commandSteps(c, b, key, entry, side) + planned.Steps = append(planned.Steps, steps...) + errs = append(errs, stepErrs...) + } + } + plan.Codelabs = append(plan.Codelabs, planned) + } + + for _, key := range side.Unresolved() { + errs = append(errs, fmt.Errorf("codelab_steps.conf: %s names no block in any codelab; the codelab it refers to has been edited, so the decision recorded there needs revisiting rather than dropping", key)) + } + return plan, errs +} + +// decide classifies a block the way the plan and the census both need it, so that the two cannot +// disagree about what a block is. A codelab declared not runnable has its reason recorded once, at +// the top, rather than a stanza per block restating it, so what no rule decides there is shown. +func decide(c Codelab, b Block, key string, side *Sidecar, notRunnable bool) (Kind, string, error) { + kind, path, err := Classify(c, b, key, side) + if err == nil && kind == KindUnclassified && notRunnable { + kind = KindIllustration + } + return kind, path, err +} + +func fileStep(b Block, key, path string, entry *Entry) Step { + mode := "write" + if entry != nil && entry.Mode != "" { + mode = entry.Mode + } + return Step{ + Key: key, Kind: "file", Line: b.Line, Section: b.Section, + Path: path, Mode: mode, Content: strings.Join(b.Body, "\n"), + } +} + +// Commands that only display something. Nothing later in a codelab can depend on one succeeding, +// so a failure - `tree -a` has no Windows counterpart that takes that flag - is recorded without +// marking every later step blocked. +var displayVerbs = map[string]bool{"tree": true, "cat": true, "which": true, "ls": true, "printenv": true} + +// commandSteps turns a block into one step per command, splitting "a && b" so that a `cd` can +// become a chdir the runner applies to itself. +// +// A stanza can name a single command as well as a whole block, as ".", for the cases where +// one line of a block needs a decision the others do not: python_intro builds a pex and then runs +// it in the same block, and only the second of those depends on a shebang. +func commandSteps(c Codelab, b Block, key string, entry *Entry, side *Sidecar) ([]Step, []error) { + commands, expects := Commands(b) + var steps []Step + var errs []error + n := 0 + for i, command := range commands { + first := len(steps) + for _, part := range splitChain(command) { + n++ + stepKey := fmt.Sprintf("%s.%d", key, n) + if m := chdirRe.FindStringSubmatch(part); m != nil { + steps = append(steps, Step{ + Key: stepKey, Kind: "chdir", Line: b.Line, + Section: b.Section, Dir: m[1], + }) + continue + } + step := Step{ + Key: stepKey, Kind: "run", Line: b.Line, + Section: b.Section, Command: part, + } + verb, _, _ := strings.Cut(part, " ") + step.NonBlocking = displayVerbs[verb] + applyEntry(&step, entry) + if own, ok := side.Lookup(stepKey); ok { + if own.Matches != "" && own.Matches != part { + errs = append(errs, fmt.Errorf("%s:%d: %s: codelab_steps.conf expects %q here, but the command now says %q. The decision recorded there was made about different text, so re-read it before updating the stanza", + c.Source, b.Line, stepKey, own.Matches, part)) + continue + } + if own.Skip != "" { + steps = append(steps, Step{ + Key: stepKey, Kind: "skip", Line: b.Line, Section: b.Section, + Reason: own.Skip, Detail: own.Reason, + }) + continue + } + applyEntry(&step, own) + } + steps = append(steps, step) + } + // Output shown after a command belongs to the last step that command produced: for + // "mkdir x && plz build", the build. + if len(expects[i]) > 0 && len(steps) > first { + steps[len(steps)-1].ExpectedOutput = expects[i] + } + } + return steps, errs +} + +// applyEntry copies a stanza's run-time directives onto a step. Directives a stanza leaves unset +// leave the step's own values alone, so a command-level stanza refines a block-level one. +func applyEntry(step *Step, e *Entry) { + if e == nil { + return + } + if len(e.Needs) > 0 { + step.Needs = e.Needs + } + if e.Assert != "" { + step.Assert = e.Assert + } + if e.Timeout != 0 { + step.Timeout = e.Timeout + } + if e.NonBlocking { + step.NonBlocking = true + } +} + +// splitChain splits "mkdir x && cd x" into its parts. Only "&&" is split: the codelabs use "|" to +// build real pipelines, which have to reach the shell intact. +func splitChain(command string) []string { + var out []string + for _, part := range strings.Split(command, "&&") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + if len(out) == 0 { + return []string{command} + } + return out +} + +// checkMatches enforces the sidecar's drift guard: the stanza says what it expects to find, and +// extraction fails if the block no longer says it. +func checkMatches(c Codelab, b Block, entry *Entry, kind Kind, path string) error { + if entry.Matches == "" { + return nil + } + var got []string + switch kind { + case KindFile: + // The path alone pins little when the stanza is what chose the path, so the first + // line of the contents is accepted too: `[Alias "puku"]` says which block is meant. + got = []string{path, firstLine(b.Body)} + case KindCommand, KindTranscript: + commands, _ := Commands(b) + got = commands + default: + // An illustration or an ignored block is pinned by its first line, which is what the + // census prints and so what a person writing the stanza has in front of them. + got = []string{firstLine(b.Body)} + } + for _, g := range got { + if strings.TrimSpace(g) == entry.Matches { + return nil + } + } + return fmt.Errorf("%s:%d: %s: codelab_steps.conf expects %q here, but the block now says %q. The decision recorded there was made about different text, so re-read it before updating the stanza", + c.Source, b.Line, entry.Key, entry.Matches, strings.Join(got, " / ")) +} + +// unclassifiedError is the authoring experience for codelab_steps.conf, so it says what to write. +func unclassifiedError(c Codelab, b Block, key string) error { + first := "" + for _, line := range b.Body { + if strings.TrimSpace(line) != "" { + first = strings.TrimSpace(line) + break + } + } + if len(first) > 60 { + first = first[:60] + "..." + } + return fmt.Errorf("%s:%d: cannot tell what this block is (fence %q, introduced by %q, starting %q).\n"+ + "Decide in test/windows/codelab_steps.conf, with the reason above it:\n\n"+ + "; why this block is what it is\n[%s]\nmatches = %s\nkind = illustration", + c.Source, b.Line, b.Lang, truncate(b.Intro, 60), first, key, first) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// Census renders one line per block: the key, what it was decided to be, and its first line. This +// is how codelab_steps.conf is authored from nothing, and the golden file the unit test diffs - +// a compact one-line-per-block record stays reviewable in a way a golden JSON plan would not. +func Census(codelabs []Codelab, side *Sidecar) string { + var b strings.Builder + for _, c := range codelabs { + entry, ok := side.Entries[c.ID] + notRunnable := ok && entry.NotRunnable != "" + for _, block := range c.Blocks { + key := Key(c, block) + kind, path, err := decide(c, block, key, side, notRunnable) + detail := path + if err != nil { + kind, detail = KindUnclassified, err.Error() + } + if detail == "" { + detail = firstLine(block.Body) + } + fmt.Fprintf(&b, "%-56s %-13s %s\n", key, kind, truncate(detail, 60)) + } + } + return b.String() +} + +func firstLine(body []string) string { + for _, line := range body { + if strings.TrimSpace(line) != "" { + return strings.TrimSpace(line) + } + } + return "" +} + +// StepKeys lists every step key in the plan, sorted. Used to check that nothing in +// codelab_known_failures.txt names a step that no longer exists. +func (p *Plan) StepKeys() []string { + var keys []string + for _, c := range p.Codelabs { + for _, s := range c.Steps { + keys = append(keys, s.Key) + } + } + sort.Strings(keys) + return keys +} diff --git a/test/windows/codelab_script/script/script_test.go b/test/windows/codelab_script/script/script_test.go new file mode 100644 index 0000000000..17d3e1c288 --- /dev/null +++ b/test/windows/codelab_script/script/script_test.go @@ -0,0 +1,244 @@ +package script + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + sidecarPath = "test/windows/codelab_steps.conf" + knownFailuresPath = "test/windows/codelab_known_failures.txt" + censusPath = "test/windows/codelab_script/script/test_data/census.txt" +) + +// The real codelabs and the real sidecar, not samples that could quietly stop resembling them. +func loadReal(t *testing.T) ([]Codelab, *Sidecar) { + t.Helper() + files, err := filepath.Glob("docs/codelabs/*.md") + require.NoError(t, err) + require.Len(t, files, 8, "the codelabs as published at https://please.build/codelabs.html") + sort.Strings(files) + + var codelabs []Codelab + for _, f := range files { + b, err := os.ReadFile(f) + require.NoError(t, err) + codelabs = append(codelabs, ParseCodelab(f, string(b))) + } + b, err := os.ReadFile(sidecarPath) + require.NoError(t, err) + side, err := ParseSidecar(sidecarPath, string(b)) + require.NoError(t, err) + return codelabs, side +} + +// The drift guard. A codelab edit that introduces a block nothing can classify, or that changes +// a block the sidecar made a decision about, fails here on Linux rather than silently changing +// what the Windows job checks. +func TestEveryBlockIsDecided(t *testing.T) { + codelabs, side := loadReal(t) + _, errs := BuildPlan(codelabs, side) + for _, err := range errs { + t.Errorf("%s", err) + } +} + +func TestCensusMatchesGolden(t *testing.T) { + codelabs, side := loadReal(t) + want, err := os.ReadFile(censusPath) + require.NoError(t, err) + assert.Equal(t, string(want), Census(codelabs, side), + "the census has changed; if the codelab edit that changed it is intended, regenerate %s with --format summary and review the diff", censusPath) +} + +// The Linux half of the shrink-only rule: an entry naming a step that no longer exists fails +// here. The Windows half, an entry whose step passed, is in run_codelabs.ps1. +func TestKnownFailuresNameRealSteps(t *testing.T) { + codelabs, side := loadReal(t) + plan, errs := BuildPlan(codelabs, side) + require.Empty(t, errs) + + names := map[string]bool{} + runnable := map[string]bool{} + for _, c := range plan.Codelabs { + names[c.ID] = true + for _, s := range c.Steps { + names[s.Key] = true + runnable[s.Key] = s.Kind == "run" + } + } + b, err := os.ReadFile(knownFailuresPath) + require.NoError(t, err) + for i, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !names[line] { + t.Errorf("%s:%d: %s names no codelab or step", knownFailuresPath, i+1, line) + } else if strings.Contains(line, "::") && !runnable[line] { + t.Errorf("%s:%d: %s is not a step that runs, so it can neither fail nor pass", knownFailuresPath, i+1, line) + } + } +} + +func parseOne(t *testing.T, md string) Codelab { + t.Helper() + return ParseCodelab("test.md", "id: test\nsummary: Test\nstatus: Published\n\n"+md) +} + +func mustSidecar(t *testing.T, conf string) *Sidecar { + t.Helper() + side, err := ParseSidecar("test.conf", conf) + require.NoError(t, err) + return side +} + +func TestFrontMatterAndKeys(t *testing.T) { + c := parseOne(t, "## Hello, world!\n```bash\nplz init\n```\n\n```bash\nplz build\n```\n## Next step\n```bash\nplz test\n```\n") + assert.Equal(t, "test", c.ID) + assert.Equal(t, "Test", c.Title) + require.Len(t, c.Blocks, 3) + assert.Equal(t, "test::hello-world/b1", Key(c, c.Blocks[0])) + assert.Equal(t, "test::hello-world/b2", Key(c, c.Blocks[1])) + // A new section restarts the ordinal, so an edit in one section renumbers no other. + assert.Equal(t, "test::next-step/b1", Key(c, c.Blocks[2])) +} + +func TestClassify(t *testing.T) { + cases := []struct { + name, md string + kind Kind + path string + }{ + {"file heading", "### `src/BUILD`\n```python\ngo_binary()\n```", KindFile, "src/BUILD"}, + {"file in prose", "Create a file `hello_service/service.go`:\n\n```golang\npackage main\n```", KindFile, "hello_service/service.go"}, + {"bare BUILD in prose", "Add a filegroup at `BUILD` in repo root:\n```python\nfilegroup()\n```", KindFile, "BUILD"}, + {"commands", "Run:\n```bash\nplz init\n```", KindCommand, ""}, + {"inline env prefix", "Like so:\n```bash\nGODEBUG=\"installgoroot=all\" go install std\n```", KindCommand, ""}, + {"transcript", "```\n$ plz build //:x\nBuild finished\n```", KindTranscript, ""}, + {"output in a bash fence", "The output should look like this:\n```bash\n.\n├── pleasew\n```", KindIllustration, ""}, + // Each of these is a line in the codelabs that would otherwise be a file to create. + {"absolute path", "if Go is at `/opt/homebrew/bin/go`:\n```ini\n[Build]\n```", KindUnclassified, ""}, + {"module path", "Let's add `github.com/stretchr/testify`:\n```text\ngo_repo()\n```", KindUnclassified, ""}, + {"no rule", "By default:\n```\n/usr/local/bin:/usr/bin:/bin\n```", KindUnclassified, ""}, + // A sentence naming a file is often followed by the command that makes it. + {"command beats prose", "Sync the changes to `third_party/go/BUILD`:\n```bash\nplz puku sync -w\n```", KindCommand, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := parseOne(t, "## S\n"+tc.md+"\n") + require.Len(t, c.Blocks, 1) + kind, path, err := Classify(c, c.Blocks[0], Key(c, c.Blocks[0]), mustSidecar(t, "")) + require.NoError(t, err) + assert.Equal(t, tc.kind, kind) + assert.Equal(t, tc.path, path) + }) + } +} + +func TestUnclassifiedIsAnError(t *testing.T) { + c := parseOne(t, "## S\nBy default:\n```\n/usr/local/bin\n```\n") + _, errs := BuildPlan([]Codelab{c}, mustSidecar(t, "")) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "test.md:7") + assert.Contains(t, errs[0].Error(), "[test::s/b1]") +} + +func TestCommandsSplitChainsIntoChdir(t *testing.T) { + c := parseOne(t, "## S\n```bash\nmkdir x && cd x\nplz init\ntree -a\n```\n") + plan, errs := BuildPlan([]Codelab{c}, mustSidecar(t, "")) + require.Empty(t, errs) + steps := plan.Codelabs[0].Steps + require.Len(t, steps, 4) + assert.Equal(t, Step{Key: "test::s/b1.1", Kind: "run", Line: 6, Section: "S", Command: "mkdir x"}, steps[0]) + assert.Equal(t, "chdir", steps[1].Kind) + assert.Equal(t, "x", steps[1].Dir) + assert.False(t, steps[2].NonBlocking) + // Only displays something, so its failure blocks nothing after it. + assert.True(t, steps[3].NonBlocking) +} + +func TestTranscriptOutputBelongsToItsCommand(t *testing.T) { + c := parseOne(t, "## S\n```\n$ plz build //:x\nBuild finished\n\n$ cat plz-out/gen/x\nhello\n```\n") + plan, errs := BuildPlan([]Codelab{c}, mustSidecar(t, "")) + require.Empty(t, errs) + steps := plan.Codelabs[0].Steps + require.Len(t, steps, 2) + assert.Equal(t, "plz build //:x", steps[0].Command) + assert.Equal(t, []string{"Build finished"}, steps[0].ExpectedOutput) + assert.Equal(t, []string{"hello"}, steps[1].ExpectedOutput) + // Advisory: nothing promoted it to an assertion. + assert.Empty(t, steps[1].Assert) +} + +func TestSidecarRequiresAReason(t *testing.T) { + _, err := ParseSidecar("test.conf", "[test::s/b1]\nkind = illustration\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "no reason") + + // A blank line between the reason and the stanza detaches it. + _, err = ParseSidecar("test.conf", "; why\n\n[test::s/b1]\nkind = illustration\n") + assert.Error(t, err) +} + +func TestSidecarDecisions(t *testing.T) { + c := parseOne(t, "## S\n### `.plzconfig`\n```\n[Plugin \"go\"]\n```\n\n```bash\nplz build\nplz-out/bin/main.pex\n```\n\n```bash\neval $(minikube docker-env)\n```\n") + side := mustSidecar(t, `; a fragment +[test::s/b1] +matches = .plzconfig +mode = merge + +; nothing after depends on it +[test::s/b2.2] +matches = plz-out/bin/main.pex +blocking = false + +; bash only +[test::s/b3] +matches = eval $(minikube docker-env) +skip = unix-shell +`) + plan, errs := BuildPlan([]Codelab{c}, side) + require.Empty(t, errs) + steps := plan.Codelabs[0].Steps + require.Len(t, steps, 4) + assert.Equal(t, "merge", steps[0].Mode) + assert.False(t, steps[1].NonBlocking) + assert.True(t, steps[2].NonBlocking) + assert.Equal(t, "skip", steps[3].Kind) + assert.Equal(t, "unix-shell", steps[3].Reason) + assert.Equal(t, "bash only", steps[3].Detail) +} + +func TestSidecarMatchesIsEnforced(t *testing.T) { + c := parseOne(t, "## S\n```bash\nplz build //:new\n```\n") + side := mustSidecar(t, "; decided about the old command\n[test::s/b1]\nmatches = plz build //:old\nskip = placeholder\n") + _, errs := BuildPlan([]Codelab{c}, side) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), `"plz build //:old"`) +} + +func TestSidecarStanzaNamingNothingIsAnError(t *testing.T) { + c := parseOne(t, "## S\n```bash\nplz build\n```\n") + side := mustSidecar(t, "; about a block since deleted\n[test::s/b9]\nkind = illustration\n") + _, errs := BuildPlan([]Codelab{c}, side) + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "test::s/b9 names no block") +} + +func TestNotRunnableCodelab(t *testing.T) { + c := parseOne(t, "## S\n```yaml\nname: CI\n```\n") + side := mustSidecar(t, "; all YAML\n[test]\nnot-runnable = no local commands\n") + plan, errs := BuildPlan([]Codelab{c}, side) + require.Empty(t, errs) + assert.Equal(t, "no local commands", plan.Codelabs[0].NotRunnable) + assert.Empty(t, plan.Codelabs[0].Steps) + assert.Equal(t, 1, plan.Codelabs[0].Blocks["illustration"]) +} diff --git a/test/windows/codelab_script/script/sidecar.go b/test/windows/codelab_script/script/sidecar.go new file mode 100644 index 0000000000..87fd05a66b --- /dev/null +++ b/test/windows/codelab_script/script/sidecar.go @@ -0,0 +1,197 @@ +package script + +import ( + "fmt" + "strconv" + "strings" +) + +// Sidecar is test/windows/codelab_steps.conf: the decisions about what the codelabs mean that the +// Markdown cannot express, kept out of the prose because the codelabs are documentation and have +// to read as documentation. +// +// Every stanza carries a reason, enforced rather than encouraged: a stanza with no comment above it +// is a parse error. This file is read later by whoever decides what to do about the codelabs that +// cannot work on Windows, and a bare directive would tell them nothing. +type Sidecar struct { + Entries map[string]*Entry + // Order the stanzas appeared in, for stable reporting. + Order []string +} + +// Entry is one stanza. +type Entry struct { + Key string + // The comment above the stanza. Required. + Reason string + // The command or path this stanza expects to find at Key. Extraction fails if the block + // there no longer says this, so an edit to a codelab cannot silently move a decision onto + // a different block. This is the drift guard; the keys themselves are readable, not hashed. + Matches string + // "file:", "command", "transcript", "illustration" or "ignore". Overrides the + // heuristics outright. + KindDirective string + // "write" (the default) or "merge", for a file. Several codelabs show a .plzconfig under a + // heading that names the whole file when what they mean is a fragment to add to what + // plz init already wrote. Writing those verbatim drops the earlier keys and manufactures a + // failure that has nothing to do with Windows. + Mode string + // A reason class saying this step cannot run here at all, e.g. "unix-shell". + Skip string + // Tools the step needs, checked on the machine at run time: docker, kubectl, minikube, + // network, github-api, interactive. + Needs []string + // A substring that must appear in the output, promoting one line from advisory to required. + Assert string + // Seconds; 0 means the runner's default. + Timeout int + // Codelab-level: this codelab has nothing to run, with this as the reason. + NotRunnable string + // Set by "blocking = false": a failure here does not mark the rest of the codelab blocked. + // For a step nothing later depends on, so that a bash-only line does not hide every + // finding after it. + NonBlocking bool + // Set when something resolved this stanza against a real block, so ParseSidecar's caller can + // report the ones that matched nothing. + Resolved bool +} + +// Kind returns the kind this stanza forces, if any, and the path for a file. +func (e *Entry) Kind(b Block) (Kind, string, error) { + if e.KindDirective == "" { + return "", "", nil + } + directive, path, hasPath := strings.Cut(e.KindDirective, ":") + switch Kind(directive) { + case KindFile: + if !hasPath || path == "" { + return "", "", fmt.Errorf(`kind = file needs a path, as "file:src/BUILD"`) + } + return KindFile, path, nil + case KindCommand, KindTranscript, KindIllustration, KindIgnore: + if hasPath { + return "", "", fmt.Errorf("kind = %s takes no path", directive) + } + return Kind(directive), "", nil + } + return "", "", fmt.Errorf("unknown kind %q", e.KindDirective) +} + +// Lookup finds the stanza for a key and marks it resolved. +func (s *Sidecar) Lookup(key string) (*Entry, bool) { + e, ok := s.Entries[key] + if ok { + e.Resolved = true + } + return e, ok +} + +// Unresolved lists stanzas that matched no block, in file order. A stanza that names nothing is +// a decision about a codelab that has since been edited, and is reported rather than ignored. +func (s *Sidecar) Unresolved() []string { + var out []string + for _, key := range s.Order { + if !s.Entries[key].Resolved { + out = append(out, key) + } + } + return out +} + +// ParseSidecar reads codelab_steps.conf. +// +// .plzconfig-flavoured: ";" comments, "[stanza]" headers, "key = value" directives. That is this +// repo's idiom for a file a person maintains by hand, and it puts the reason on the line above the +// decision where it belongs. +func ParseSidecar(filename, content string) (*Sidecar, error) { + side := &Sidecar{Entries: map[string]*Entry{}} + var reason []string + var current *Entry + + for i, line := range strings.Split(content, "\n") { + lineno := i + 1 + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + // A blank line separates the file's own header from the first stanza, and one + // stanza from the next. It also discards a comment, so that a reason cannot + // drift away from what it explains. + reason = nil + continue + } + if strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#") { + reason = append(reason, strings.TrimSpace(strings.TrimLeft(trimmed, ";# "))) + continue + } + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + key := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) + if key == "" { + return nil, fmt.Errorf("%s:%d: empty stanza name", filename, lineno) + } + if _, dup := side.Entries[key]; dup { + return nil, fmt.Errorf("%s:%d: %s appears twice", filename, lineno, key) + } + if len(reason) == 0 { + return nil, fmt.Errorf("%s:%d: %s has no reason above it; every stanza here needs one, because this file is what the decision about the codelabs will be taken from", filename, lineno, key) + } + current = &Entry{Key: key, Reason: strings.Join(reason, " ")} + side.Entries[key] = current + side.Order = append(side.Order, key) + reason = nil + continue + } + + name, value, ok := strings.Cut(trimmed, "=") + if !ok { + return nil, fmt.Errorf("%s:%d: expected a stanza, a comment, or 'name = value'", filename, lineno) + } + if current == nil { + return nil, fmt.Errorf("%s:%d: %s appears before any stanza", filename, lineno, strings.TrimSpace(name)) + } + if err := current.set(strings.TrimSpace(name), strings.TrimSpace(value)); err != nil { + return nil, fmt.Errorf("%s:%d: %s: %w", filename, lineno, current.Key, err) + } + } + return side, nil +} + +func (e *Entry) set(name, value string) error { + switch strings.ToLower(name) { + case "matches": + e.Matches = value + case "kind": + e.KindDirective = value + case "mode": + if value != "write" && value != "merge" { + return fmt.Errorf("mode is write or merge, not %q", value) + } + e.Mode = value + case "skip": + e.Skip = value + case "needs": + for _, need := range strings.Split(value, ",") { + if need = strings.TrimSpace(need); need != "" { + e.Needs = append(e.Needs, need) + } + } + case "assert": + e.Assert = value + case "timeout": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("timeout is a number of seconds: %w", err) + } + e.Timeout = n + case "not-runnable": + e.NotRunnable = value + case "blocking": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("blocking is true or false: %w", err) + } + e.NonBlocking = !b + default: + return fmt.Errorf("unknown directive %q", name) + } + return nil +} diff --git a/test/windows/codelab_script/script/test_data/census.txt b/test/windows/codelab_script/script/test_data/census.txt new file mode 100644 index 0000000000..c14e150b38 --- /dev/null +++ b/test/windows/codelab_script/script/test_data/census.txt @@ -0,0 +1,167 @@ +genrule::genrule/b1 command mkdir custom_rules && cd custom_rules +genrule::genrule/b2 file BUILD +genrule::genrule/b3 command echo "the quick brown fox jumped over the lazy dog" > file.t... +genrule::genrule/b4 transcript $ plz build //:word_count +genrule::the-build-directory/b1 illustration $ plz build --shell :word_count +genrule::the-build-directory/b2 illustration bash-4.4$ printenv +genrule::build-definitions/b1 file build_defs/word_count.build_defs +genrule::build-definitions/b2 file build_defs/BUILD +genrule::build-definitions/b3 file BUILD +genrule::build-definitions/b4 command plz build //:word_count +genrule::build-definitions/b5 illustration Build finished; total time 30ms, incrementality 100.0%. Outp... +genrule::managing-tools/b1 file build_defs/word_count.build_defs +genrule::managing-tools/b2 file tools/wc.sh +genrule::managing-tools/b3 file tools/BUILD +genrule::managing-tools/b4 transcript $ plz run //tools:wc -- file.txt +genrule::managing-tools/b5 file BUILD +genrule::managing-tools/b6 transcript $ plz build //:lines_words_and_chars //:just_words +genrule::configuration/b1 file .plzconfig +genrule::configuration/b2 file build_defs/word_count.build_defs +genrule::configuration/b3 file BUILD +genrule::configuration/b4 transcript $ plz build //:lines_words_and_chars //:just_words +github_actions::github-actions/b1 illustration name: CI +github_actions::please-build/b1 illustration name: CI +github_actions::setup-please-action/b1 illustration - name: Test +github_actions::setup-please-action/b2 illustration - name: Checkout code +github_actions::setup-please-action/b3 illustration name: CI +go_intro::initialising-your-project/b1 command mkdir getting_started_go && cd getting_started_go +go_intro::initialising-your-project/b2 transcript $ tree -a +go_intro::initialising-your-project/b3 illustration [parse] +go_intro::setting-up-our-import-path/b1 file .plzconfig +go_intro::setting-up-your-toolchain/b1 file third_party/go/BUILD +go_intro::setting-up-your-toolchain/b2 file .plzconfig +go_intro::setting-up-your-toolchain/b3 illustration /usr/local/bin:/usr/bin:/bin +go_intro::setting-up-your-toolchain/b4 ignore [Build] +go_intro::setting-up-your-toolchain/b5 command GODEBUG="installgoroot=all" go install std +go_intro::hello-world/b1 file src/main.go +go_intro::hello-world/b2 file src/BUILD +go_intro::hello-world/b3 command plz run //src:main +go_intro::hello-world/b4 illustration Hello, world! +go_intro::adding-packages/b1 file src/greetings/greetings.go +go_intro::adding-packages/b2 file src/greetings/BUILD +go_intro::adding-packages/b3 command plz build //src/greetings +go_intro::adding-packages/b4 illustration Build finished; total time 290ms, incrementality 50.0%. Outp... +go_intro::using-our-new-package/b1 file src/BUILD +go_intro::using-our-new-package/b2 file src/main.go +go_intro::using-our-new-package/b3 transcript $ plz run //src:main +go_intro::testing-our-code/b1 file src/greetings/greetings_test.go +go_intro::testing-our-code/b2 file src/greetings/BUILD +go_intro::testing-our-code/b3 transcript $ plz test //src/... +go_intro::testing-our-code/b4 file src/greetings/greetings_test.go +go_intro::testing-our-code/b5 file src/greetings/BUILD +go_intro::testing-our-code/b6 transcript $ plz test //src/... +go_intro::third-party-dependencies/b1 transcript $ plz run ///go//tools:please_go -- get github.com/stretchr/... +go_intro::third-party-dependencies/b2 file third_party/go/BUILD +go_intro::third-party-dependencies/b3 file src/greetings/greetings_test.go +go_intro::third-party-dependencies/b4 file src/greetings/BUILD +go_intro::third-party-dependencies/b5 transcript $ plz test +k8s::creating-a-service/b1 command plz init +k8s::creating-a-service/b2 file third_party/go/BUILD +k8s::creating-a-service/b3 file .plzconfig +k8s::creating-a-service/b4 file hello_service/service.go +k8s::creating-a-service/b5 file hello_service/BUILD +k8s::creating-a-service/b6 command plz run //hello_service:hello_service && curl localhost:8000 +k8s::creating-a-service/b7 illustration [1] 28694 +k8s::building-a-docker-image/b1 file common/docker/Dockerfile-base +k8s::building-a-docker-image/b2 command plz init plugin shell && plz init plugin docker +k8s::building-a-docker-image/b3 file common/docker/BUILD +k8s::building-a-docker-image/b4 transcript $ plz build //common/docker:base +k8s::building-a-docker-image/b5 transcript $ cat plz-out/bin/common/docker/base.sh +k8s::building-a-docker-image/b6 command plz run //common/docker:base +k8s::using-our-base-image/b1 file hello_service/k8s/Dockerfile +k8s::using-our-base-image/b2 file hello_service/k8s/BUILD +k8s::using-our-base-image/b3 transcript $ plz build //hello_service/k8s:image +k8s::creating-a-kubernetes-deployment/b1 file hello_service/k8s/deployment.yaml +k8s::creating-a-kubernetes-deployment/b2 file hello_service/k8s/service.yaml +k8s::creating-a-kubernetes-deployment/b3 file hello_service/k8s/BUILD +k8s::creating-a-kubernetes-deployment/b4 transcript $ plz build //hello_service/k8s +k8s::creating-a-kubernetes-deployment/b5 transcript $ plz build //hello_service/k8s:k8s_push +k8s::local-testing-with-minikube/b1 file third_party/binary/BUILD +k8s::local-testing-with-minikube/b2 command plz run //third_party/binary:minikube -- start +k8s::local-testing-with-minikube/b3 command eval $(plz run //third_party/binary:minikube -- docker-env) +k8s::local-testing-with-minikube/b4 command plz run //hello_service/k8s:image_load && plz run //hello_se... +k8s::local-testing-with-minikube/b5 transcript $ kubectl port-forward service/hello-svc 8000:8000 && curl l... +k8s::please-deploy/b1 command plz run sequential --include docker-build --include k8s-push... +k8s::please-deploy/b2 file .plzconfig +k8s::please-deploy/b3 command plz deploy //hello_service/... +k8s::docker-build-and-build-systems/b1 illustration docker_image( +plz_query::setting-up/b1 command git clone https://github.com/thought-machine/please-codelabs +plz_query::setting-up/b2 illustration Cloning into 'please-codelabs'... +plz_query::setting-up/b3 command cd please-codelabs/getting_started_go +plz_query::finding-dependencies-of-a-target/b1 transcript $ plz query deps //src/greetings:greetings_test +plz_query::finding-dependencies-of-a-target/b2 command plz query print ///third_party/go/github.com_stretchr_testif... +plz_query::finding-dependencies-of-a-target/b3 transcript $ cat plz-out/subrepos/third_party/go/github.com_stretchr_te... +plz_query::finding-dependencies-of-a-target/b4 transcript $ plz query deps //src/greetings:greetings --hidden +plz_query::finding-dependencies-of-a-target/b5 transcript $ plz query print //src/greetings:_greetings#srcs +plz_query::reverse-dependencies/b1 transcript $ plz query revdeps ///third_party/go/github.com_stretchr_te... +plz_query::reverse-dependencies/b2 transcript $ plz query revdeps ///third_party/go/github.com_stretchr_te... +plz_query::composing-plz-commands/b1 transcript $ plz query revdeps ///third_party/go/github.com_stretchr_te... +plz_query::including-and-excluding-targets/b1 transcript $ plz query revdeps --exclude //src/greetings:greetings_test... +plz_query::including-and-excluding-targets/b2 transcript $ plz query revdeps --level=-1 ///third_party/go/github.com_... +plz_query::including-and-excluding-targets/b3 command plz build --include go --exclude //third_party/go/... +plz_query::including-and-excluding-targets/b4 file src/greetings/BUILD +plz_query::including-and-excluding-targets/b5 transcript $ plz query alltargets --include=my_label +plz_query::including-and-excluding-targets/b6 command plz test --exclude my_label +puku::initialising-your-project-and-running-puku-with-please/b1 command mkdir puku_sync && cd puku_sync +puku::initialising-your-project-and-running-puku-with-please/b2 file .plzconfig +puku::initialising-your-project-and-running-puku-with-please/b3 ignore [please] +puku::initialising-your-project-and-running-puku-with-please/b4 file .plzconfig +puku::initialising-your-project-and-running-puku-with-please/b5 file third_party/binary/BUILD +puku::initialising-your-project-and-running-puku-with-please/b6 file BUILD +puku::initialising-your-project-and-running-puku-with-please/b7 file .plzconfig +puku::initialising-your-project-and-running-puku-with-please/b8 illustration /usr/local/bin:/usr/bin:/bin +puku::initialising-your-project-and-running-puku-with-please/b9 command which go +puku::initialising-your-project-and-running-puku-with-please/b10 ignore [Build] +puku::initialising-your-project-and-running-puku-with-please/b11 ignore [Build] +puku::initialising-your-project-and-running-puku-with-please/b12 command GODEBUG="installgoroot=all" go install std +puku::adding-and-updating-modules/b1 file src/hello/hello.go +puku::adding-and-updating-modules/b2 command go get github.com/google/uuid +puku::adding-and-updating-modules/b3 command plz puku sync -w +puku::adding-and-updating-modules/b4 file src/hello/BUILD +puku::adding-and-updating-modules/b5 command plz puku fmt //src/hello +puku::adding-and-updating-modules/b6 command plz run //src/hello +puku::adding-and-updating-modules/b7 command GOTOOLCHAIN=local go get github.com/google/uuid@v1.6.0 +puku::adding-and-updating-modules/b8 command GOTOOLCHAIN=local go get -u github.com/google/uuid +puku::adding-and-updating-modules/b9 command go get +puku::adding-and-updating-modules/b10 command go get +puku::stop-a-module-from-updating/b1 command go mod edit -exclude github.com/example/module@v2.0.0 +puku::stop-a-module-from-updating/b2 command go mod edit -dropexclude github.com/example/module@v2.0.0 +puku::stop-a-module-from-updating/b3 command go mod edit -replace github.com/example/module=github.com/ex... +puku::stop-a-module-from-updating/b4 command go mod edit -dropreplace github.com/example/module +puku::stop-a-module-from-updating/b5 command go mod edit -replace github.com/google/uuid=github.com/googl... +puku::removing-modules/b1 command plz query revdeps //third_party/go:module_name --level=-1 | ... +puku::removing-modules/b2 command go mod edit -droprequire github.com/example/module +puku::removing-modules/b3 command plz puku sync -w +puku::using-new-modules/b1 illustration go_library( +puku::using-new-modules/b2 illustration go_repo( +puku::using-new-modules/b3 illustration go_library( +puku::using-new-modules/b4 command plz puku watch //src/... +python_intro::initialising-your-project/b1 command mkdir getting_started_python && cd getting_started_python +python_intro::initialising-your-project/b2 ignore [build] +python_intro::initialising-your-project/b3 transcript $ tree -a +python_intro::initialising-your-project/b4 illustration [parse] +python_intro::hello-world/b1 file src/main.py +python_intro::hello-world/b2 file src/BUILD +python_intro::hello-world/b3 transcript $ plz run //src:main +python_intro::adding-modules/b1 file src/greetings/greetings.py +python_intro::adding-modules/b2 file src/greetings/BUILD +python_intro::adding-modules/b3 transcript $ plz build //src/greetings +python_intro::adding-modules/b4 transcript $ plz build //src:main +python_intro::using-our-new-module/b1 file src/BUILD +python_intro::using-our-new-module/b2 file src/main.py +python_intro::using-our-new-module/b3 transcript $ plz run //src:main +python_intro::testing-our-code/b1 file src/greetings/greetings_test.py +python_intro::testing-our-code/b2 file src/greetings/BUILD +python_intro::testing-our-code/b3 transcript $ plz test //src/... +python_intro::third-party-dependencies/b1 file third_party/python/BUILD +python_intro::third-party-dependencies/b2 file .plzconfig +python_intro::third-party-dependencies/b3 ignore [plugin "python"] +python_intro::third-party-dependencies/b4 file src/greetings/greetings.py +python_intro::third-party-dependencies/b5 file src/greetings/BUILD +python_intro::third-party-dependencies/b6 transcript $ plz run //src:main +using_plugins::initialising-your-please-repo/b1 command plz init +using_plugins::initialising-your-please-repo/b2 illustration . +using_plugins::how-to-install-a-plugin/b1 command plz init plugin go +using_plugins::how-to-install-a-plugin/b2 illustration . +using_plugins::how-to-install-a-plugin/b3 illustration [parse] +using_plugins::how-to-install-a-plugin/b4 illustration plugin_repo( diff --git a/test/windows/codelab_steps.conf b/test/windows/codelab_steps.conf new file mode 100644 index 0000000000..39d33dda41 --- /dev/null +++ b/test/windows/codelab_steps.conf @@ -0,0 +1,322 @@ +; Decisions about what the codelabs mean, for //test/windows/codelab_script, which reduces +; docs/codelabs/*.md to a plan that run_codelabs.ps1 replays on a real Windows machine. +; +; Kept here rather than in the prose, because the codelabs are documentation and have to read as +; documentation. Nothing in this file edits a codelab or makes one pass: it says what a block is +; where the Markdown cannot, and which steps cannot run on a CI machine at all. +; +; Every stanza needs a comment directly above it saying why; the parser refuses one without. +; "matches" pins the text the decision was made about, so an edit to a codelab fails extraction +; on Linux instead of quietly moving a decision onto a different block. Keys are printed by: +; +; plz run //test/windows/codelab_script -- --sidecar test/windows/codelab_steps.conf \ +; --format summary docs/codelabs/*.md +; +; Directives: matches, kind (file: | command | transcript | illustration | ignore), +; mode (write | merge), skip (a reason class), needs (docker, kubectl, minikube, ...), +; blocking (false: a failure here does not block the rest), assert, timeout, not-runnable. +; +; Merging a .plzconfig fragment edits the file the way a reader would: a key the section already +; has is replaced, a new key is added to its section, and a new section is appended. Appending the +; fragment verbatim was tried first and manufactured a failure on the first native run, because +; plz init plugin go already writes GoTool and a plugin section refuses a repeated key. + +; ---- github_actions ----------------------------------------------------------------------- + +; Teaches CI configuration, and every block is the YAML of a workflow. There is nothing in it +; anyone runs locally, on any OS. +[github_actions] +not-runnable = no local commands; every block is GitHub Actions YAML + +; ---- genrule ------------------------------------------------------------------------------ + +; A walk-through of an interactive `plz build --shell` session, bash-4.4$ prompts included. It +; shows what the reader will see after typing, not something a script can replay. +[genrule::the-build-directory/b1] +matches = $ plz build --shell :word_count +kind = illustration + +; The printenv output from inside that same interactive shell. +[genrule::the-build-directory/b2] +matches = bash-4.4$ printenv +kind = illustration + +; A [buildconfig] section to add to the .plzconfig plz init wrote, not the whole file. Written +; verbatim it would drop everything plz init put there. +[genrule::configuration/b1] +matches = .plzconfig +mode = merge + +; ---- go_intro ----------------------------------------------------------------------------- + +; Shows what plz init plugin go has already written ("Please will have initialised this ... for +; us"). Writing it again would replace the generated file with a copy that differs from it. +[go_intro::initialising-your-project/b3] +matches = [parse] +kind = illustration + +; Adds ImportPath to the plugin section plz init plugin go wrote. +[go_intro::setting-up-our-import-path/b1] +matches = .plzconfig +mode = merge + +; Adds GoTool to the same section, for the managed toolchain the codelab recommends. +[go_intro::setting-up-your-toolchain/b2] +matches = .plzconfig +mode = merge + +; The default [build] path, shown for information. +[go_intro::setting-up-your-toolchain/b3] +matches = /usr/local/bin:/usr/bin:/bin +kind = illustration + +; The alternative to the managed toolchain: Go from the system PATH. The codelab offers the two +; as a choice and recommends the other, which is the one followed here. Its example is a +; colon-separated Unix path with no Windows form given, which is worth knowing when the docs +; decision is taken; on Windows the default [build] path is empty. +[go_intro::setting-up-your-toolchain/b4] +matches = [Build] +kind = ignore + +; Part of the same system-PATH alternative, not the recommended route. Also bash syntax: an +; inline environment prefix is a command name to PowerShell. +[go_intro::setting-up-your-toolchain/b5] +matches = GODEBUG="installgoroot=all" go install std +skip = alternative-route + +; ---- python_intro ------------------------------------------------------------------------- + +; Conditional ("If Python isn't in this path") and a template rather than a value: +; $YOUR_PYTHON_INSTALL_HERE followed by a colon-separated Unix path. There is no Windows form to +; follow. On Windows the default [build] path is empty, so whatever this costs shows up in the +; steps that need an interpreter, and is recorded there. +[python_intro::initialising-your-project/b2] +matches = [build] +kind = ignore + +; Shows what plz init plugin python has already written. +[python_intro::initialising-your-project/b4] +matches = [parse] +kind = illustration + +; Runs the built .pex directly, which depends on a shebang and an executable bit. Nothing later +; in the codelab depends on it, so a failure here is recorded without blocking what follows. +[python_intro::adding-modules/b4.2] +matches = plz-out/bin/src/main.pex +blocking = false + +; Adds ModuleDir to the plugin section plz init plugin python wrote. +[python_intro::third-party-dependencies/b2] +matches = .plzconfig +mode = merge + +; Conditional: only "if you encounter an error eg. no such option: --system". Not an instruction +; to follow unconditionally. +[python_intro::third-party-dependencies/b3] +matches = [plugin "python"] +kind = ignore + +; ---- puku --------------------------------------------------------------------------------- + +; A [buildconfig] entry to add to the .plzconfig plz init wrote. +[puku::initialising-your-project-and-running-puku-with-please/b2] +matches = .plzconfig +mode = merge + +; "Uncomment and edit the following lines" - an edit with no verbatim reading. Carried out, it +; pins Please 17.22.0, so please.exe would try to replace itself with an upstream release that +; has no Windows build, and the check would stop testing the binary it was given. +[puku::initialising-your-project-and-running-puku-with-please/b3] +matches = [please] +kind = ignore + +; The Puku alias every later step uses as `plz puku`. Introduced as "optional but convenient", +; so no heading names the file; it is a fragment for .plzconfig. +[puku::initialising-your-project-and-running-puku-with-please/b4] +matches = [Alias "puku"] +kind = file:.plzconfig +mode = merge + +; Adds ModFile to the plugin section plz init plugin go wrote. +[puku::initialising-your-project-and-running-puku-with-please/b7] +matches = .plzconfig +mode = merge + +; The default [build] path, shown for information. +[puku::initialising-your-project-and-running-puku-with-please/b8] +matches = /usr/local/bin:/usr/bin:/bin +kind = illustration + +; An example [build] path for Go installed by Homebrew on macOS. Conditional, and Unix-only; the +; codelab's only Windows note is to use where.exe to find Go, with no path form to put here. +[puku::initialising-your-project-and-running-puku-with-please/b10] +matches = [Build] +kind = ignore + +; The same example for Go under /usr/local/go. +[puku::initialising-your-project-and-running-puku-with-please/b11] +matches = [Build] +kind = ignore + +; Bash syntax: an inline environment prefix is a command name to PowerShell. Later steps need +; the standard library this installs, but blocking on it would hide every other finding in the +; codelab behind a line whose failure is already understood. +[puku::initialising-your-project-and-running-puku-with-please/b12] +matches = GODEBUG="installgoroot=all" go install std +blocking = false + +; Bash syntax, as above. The sync after it still runs. +[puku::adding-and-updating-modules/b7.1] +matches = GOTOOLCHAIN=local go get github.com/google/uuid@v1.6.0 +blocking = false + +; Bash syntax, as above. +[puku::adding-and-updating-modules/b8.1] +matches = GOTOOLCHAIN=local go get -u github.com/google/uuid +blocking = false + +; Troubleshooting advice with as a placeholder. Not something to run as written. +[puku::adding-and-updating-modules/b9] +matches = go get +skip = placeholder + +; The same advice for a different error message. +[puku::adding-and-updating-modules/b10] +matches = go get +skip = placeholder + +; github.com/example/module is a stand-in, not a real module, and `go get -u` on it two blocks +; later fails on every platform. The four blocks demonstrate go.mod directives on it and are +; skipped together; the real scenario with google/uuid that follows them runs. +[puku::stop-a-module-from-updating/b1] +matches = go mod edit -exclude github.com/example/module@v2.0.0 +skip = placeholder + +; As above. +[puku::stop-a-module-from-updating/b2] +matches = go mod edit -dropexclude github.com/example/module@v2.0.0 +skip = placeholder + +; As above. +[puku::stop-a-module-from-updating/b3] +matches = go mod edit -replace github.com/example/module=github.com/example/module@v1.5.0 +skip = placeholder + +; As above. +[puku::stop-a-module-from-updating/b4] +matches = go mod edit -dropreplace github.com/example/module +skip = placeholder + +; //third_party/go:module_name is a placeholder, as the prose around it says. +[puku::removing-modules/b1] +matches = plz query revdeps //third_party/go:module_name --level=-1 | grep -v //third_party/go +skip = placeholder + +; github.com/example/module again: dropping a requirement that was never added. +[puku::removing-modules/b2] +matches = go mod edit -droprequire github.com/example/module +skip = placeholder + +; Examples of how a BUILD file could look, for a package (mylib) the codelab never creates. +[puku::using-new-modules/b1] +matches = go_library( +kind = illustration + +; As above: an alternative form of go_repo, not the one the codelab built. +[puku::using-new-modules/b2] +matches = go_repo( +kind = illustration + +; As above. +[puku::using-new-modules/b3] +matches = go_library( +kind = illustration + +; Watches the tree until interrupted. It never exits by itself, on any platform. +[puku::using-new-modules/b4] +matches = plz puku watch //src/... +skip = interactive + +; ---- k8s ---------------------------------------------------------------------------------- + +; "Add a go toolchain to `third_party/go/BUILD`" - no trailing colon, so the prose rule does not +; read it, and the fence says go when the content is a BUILD file. +[k8s::creating-a-service/b2] +matches = go_toolchain( +kind = file:third_party/go/BUILD + +; "And configure the plugin:" - a fragment for the plugin section plz init plugin go wrote. +[k8s::creating-a-service/b3] +matches = [Plugin "go"] +kind = file:.plzconfig +mode = merge + +; Starts an HTTP server in the foreground, then relies on bash job control and pkill to reach +; and stop it. A non-interactive runner cannot do that on any platform: the first command never +; returns. +[k8s::creating-a-service/b6] +matches = plz run //hello_service:hello_service && curl localhost:8000 +skip = interactive + +; Loads the image into a Docker daemon. +[k8s::building-a-docker-image/b6] +matches = plz run //common/docker:base +needs = docker + +; "Create a `hello_service/k8s/Dockerfile` for our hello service:" wraps onto a second line, so the +; path is not on the line the prose rule reads. A real file the image build below depends on. +[k8s::using-our-base-image/b1] +matches = FROM //common/docker:base +kind = file:hello_service/k8s/Dockerfile + +; Starts a minikube cluster, whose default driver is Docker. +[k8s::local-testing-with-minikube/b2] +matches = plz run //third_party/binary:minikube -- start +needs = docker + +; bash command substitution feeding eval. No PowerShell reading. +[k8s::local-testing-with-minikube/b3] +matches = eval $(plz run //third_party/binary:minikube -- docker-env) +skip = unix-shell + +; Loads the image and applies the manifests to the cluster. +[k8s::local-testing-with-minikube/b4] +needs = docker, kubectl + +; Holds a port-forward open in the foreground and curls through it. +[k8s::local-testing-with-minikube/b5] +matches = kubectl port-forward service/hello-svc 8000:8000 && curl localhost:8000 +skip = interactive + +; Builds and pushes to the cluster. +[k8s::please-deploy/b1] +matches = plz run sequential --include docker-build --include k8s-push //hello_service/... +needs = docker, kubectl + +; The deploy alias, for .plzconfig. +[k8s::please-deploy/b2] +matches = .plzconfig +mode = merge + +; The same deploy, through the alias. +[k8s::please-deploy/b3] +matches = plz deploy //hello_service/... +needs = docker, kubectl + +; An example rule in a discussion section, for a package the codelab never creates. +[k8s::docker-build-and-build-systems/b1] +matches = docker_image( +kind = illustration + +; ---- using_plugins ------------------------------------------------------------------------ + +; Shows what plz init plugin go has already written. +[using_plugins::how-to-install-a-plugin/b3] +matches = [parse] +kind = illustration + +; Shows the plugins/BUILD that was generated, pinned at v1.29.0. Writing it would replace +; whichever revision plz init plugin just chose with an older one. +[using_plugins::how-to-install-a-plugin/b4] +matches = plugin_repo( +kind = illustration diff --git a/test/windows/known_failures.txt b/test/windows/known_failures.txt new file mode 100644 index 0000000000..3b68bd31da --- /dev/null +++ b/test/windows/known_failures.txt @@ -0,0 +1,5 @@ +# Tests that are known to fail on Windows. One "name" or "name::TestCase" per line, +# with a comment above each saying why and linking whatever tracks it. +# +# A test listed here that starts passing fails the job too, so that this list only ever +# shrinks. Nothing is in it yet; the first native run is what fills it in. diff --git a/test/windows/offline_repo/.plzconfig b/test/windows/offline_repo/.plzconfig new file mode 100644 index 0000000000..5f291f6328 --- /dev/null +++ b/test/windows/offline_repo/.plzconfig @@ -0,0 +1,20 @@ +; A repo that asks for a plugin the ordinary way, so that the only thing under test is where +; the plugin comes from. Named BUILD_FILE so the outer repo doesn't parse this as one of its +; own packages. +[parse] +; BUILD_FILE so the outer repo doesn't parse this as one of its own packages, and BUILD as +; well because a subrepo inherits this list: without it the plugin's own build files, which +; are named BUILD, cannot be found once it has been unpacked. Nothing here is called BUILD. +BuildFileName = BUILD_FILE +BuildFileName = BUILD +preloadsubincludes = ///shell//build_defs:shell + +[Plugin "shell"] +Target = //plugins:shell + +; No dir cache. Please's cache is content-addressed and lands outside the test's tmp dir, so +; leaving it on lets a run replay artifacts an earlier, differently-built binary produced - +; which has already caused one false pass during this port. Here it would also let a plugin +; that was once downloaded stand in for the bundled one, which is the whole point. +[cache] +dir = diff --git a/test/windows/offline_repo/BUILD_FILE b/test/windows/offline_repo/BUILD_FILE new file mode 100644 index 0000000000..130126bd5f --- /dev/null +++ b/test/windows/offline_repo/BUILD_FILE @@ -0,0 +1,13 @@ +# An sh_binary rather than a genrule: building one needs the plugin's build defs, the bundled +# arcat to write its payload, and busybox to run the action, so a single target covers the +# whole chain. +sh_binary( + name = "greet", + main = "greet.sh", + deps = [":lib"], +) + +sh_library( + name = "lib", + src = "lib.sh", +) diff --git a/test/windows/offline_repo/greet.sh b/test/windows/offline_repo/greet.sh new file mode 100644 index 0000000000..ff7af02cf1 --- /dev/null +++ b/test/windows/offline_repo/greet.sh @@ -0,0 +1,3 @@ +#!/bin/sh +. "$(dirname "$0")/lib.sh" +echo "$GREETING" diff --git a/test/windows/offline_repo/lib.sh b/test/windows/offline_repo/lib.sh new file mode 100644 index 0000000000..eaf1dcc35e --- /dev/null +++ b/test/windows/offline_repo/lib.sh @@ -0,0 +1 @@ +GREETING="offline ok" diff --git a/test/windows/offline_repo/plugins/BUILD_FILE b/test/windows/offline_repo/plugins/BUILD_FILE new file mode 100644 index 0000000000..6be10a9666 --- /dev/null +++ b/test/windows/offline_repo/plugins/BUILD_FILE @@ -0,0 +1,8 @@ +# Stock, with the revision the plugin is released as rather than the one that is bundled. The +# bundled archive answers for whatever revision is asked for, which is what makes an ordinary +# repo work offline and is also the footgun documented in plugin_revisions.txt. +plugin_repo( + name = "shell", + plugin = "shell-rules", + revision = "v0.2.1", +) diff --git a/test/windows/python/BUILD b/test/windows/python/BUILD new file mode 100644 index 0000000000..36e1ed22e1 --- /dev/null +++ b/test/windows/python/BUILD @@ -0,0 +1,30 @@ +# The subject of //test/windows:pex_test. It is built for windows_amd64 and run under Wine; +# nothing runs it here. +python_test( + name = "pex_test", + srcs = ["pex_test.py"], + data = [":data"], + labels = ["manual"], + visibility = ["//test/windows:all"], + deps = ["//third_party/python:six"], +) + +# The test reads this at the path it has here, so the Wine rule has to place it itself: a +# python_test's own data doesn't come along when another rule depends on the test. +filegroup( + name = "data", + srcs = ["data.txt"], + test_only = True, + visibility = ["//test/windows:all"], +) + +# Windows has no exec(), so the .pex preamble runs the interpreter as a child process and has to +# pass its exit status back itself. Getting that wrong would make every failing python_binary +# look like it succeeded. +python_binary( + name = "exit_code", + labels = ["manual"], + main = "exit_code.py", + test_only = True, + visibility = ["//test/windows:all"], +) diff --git a/test/windows/python/data.txt b/test/windows/python/data.txt new file mode 100644 index 0000000000..cf7af95867 --- /dev/null +++ b/test/windows/python/data.txt @@ -0,0 +1 @@ +hello from a data file diff --git a/test/windows/python/exit_code.py b/test/windows/python/exit_code.py new file mode 100644 index 0000000000..4ace8c740d --- /dev/null +++ b/test/windows/python/exit_code.py @@ -0,0 +1,6 @@ +"""Exits with the code it is given, so that something can check it arrives.""" + +import sys + +if __name__ == '__main__': + sys.exit(int(sys.argv[1])) diff --git a/test/windows/python/pex_test.py b/test/windows/python/pex_test.py new file mode 100644 index 0000000000..fe98cd84f7 --- /dev/null +++ b/test/windows/python/pex_test.py @@ -0,0 +1,42 @@ +"""What a .pex has to get right on Windows, none of which is visible on Linux. + +The interesting part is not the assertions - it is that this runs at all. Getting here means +please_pex prepended a preamble Windows will execute, that preamble found a Python interpreter +and handed it the .pex, and Python imported this module out of the zip. +""" + +import os +import sys +import unittest + + +class PexTest(unittest.TestCase): + def test_running_on_windows(self): + """The whole point: this is a Windows interpreter, not the host one.""" + self.assertEqual('nt', os.name) + self.assertEqual('win32', sys.platform) + + def test_imported_from_the_zip(self): + """sys.path[0] is the .pex itself, and this module came out of it.""" + self.assertTrue(sys.argv[0].endswith('.pex.exe'), sys.argv[0]) + self.assertIn('.pex.exe', __file__) + + def test_reads_a_data_file(self): + """Data files land beside the .pex rather than inside it.""" + with open('test/windows/python/data.txt') as f: + self.assertEqual('hello from a data file', f.read().strip()) + + def test_third_party_import(self): + """Third-party code is imported from a directory inside the zip, by a meta path hook. + + Setting that hook up scans the zip for distribution metadata, whose member names are + always /-separated whoever wrote them. Building the pattern for that out of os.sep made + it a backslash here, which the regex compiler read as an escape - so every .pex died on + startup, before any of its own code ran. + """ + import six + self.assertTrue(six.PY3) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/windows/run_codelabs.ps1 b/test/windows/run_codelabs.ps1 new file mode 100644 index 0000000000..ef1b8e8c25 --- /dev/null +++ b/test/windows/run_codelabs.ps1 @@ -0,0 +1,459 @@ +<# +.SYNOPSIS + Replays the published codelabs on Windows, and records what a person following them would hit. + +.DESCRIPTION + //test/windows:codelab_plan reduces docs/codelabs/*.md to an ordered list of steps: files to + write, commands to run, directories to change into. This replays that list with the Windows + release, the way a person reading https://please.build/codelabs.html on Windows would. + + It answers a different question from run_native_tests.ps1 and run_native_probes.ps1. Those ask + whether Please works on Windows. This asks whether the documentation does, and most of what it + finds is not a bug in Please: bash syntax PowerShell does not accept, Unix tools that are not + there, and plugins whose tools have no Windows release. That is the finding, and it is recorded + rather than worked around. Nothing here edits a codelab to make it pass. + + Each command is handed to pwsh exactly as the codelab writes it, as an encoded command so that + no quoting of this script's stands between the text and the parser. The shell is the subject + under test, not plumbing: a reader types these lines into PowerShell, so that is where they run. + + Every step ends as one of: + + PASS it exited zero, and contained the sidecar's assert text if there was one + FAIL it did not + KNOWN it failed, and codelab_known_failures.txt says so, with a reason + SKIPPED the sidecar says it cannot run here, or it needs a tool this machine lacks + BLOCKED an earlier step in the same codelab failed, so this one was never reached + + BLOCKED is never counted as a failure. Without it one missing plugin tool in go_intro would + manufacture a dozen more failures, and the one entry that matters would drown. + + What a codelab shows a command printing is compared and reported, but never fails a step. The + codelabs' output is full of timings and a randomly chosen greeting, and asserting on it would + produce flakes that discredit the whole check. + +.EXAMPLE + # On Linux, before pushing: parses the plan, resolves the known failures, prints what would run. + pwsh ./test/windows/run_codelabs.ps1 -DryRun -Plan plz-out/gen/test/windows/codelab_plan.json ` + -KnownFailures test/windows/codelab_known_failures.txt +#> +param( + # codelab_plan.json, as built by //test/windows:codelab_plan. + [Parameter(Mandatory)][string]$Plan, + # A directory holding the release zip. Not needed for -DryRun. + [string]$Release = '', + [string]$Logs = '', + # One "codelab_id" or "codelab_id::step-key" per line, with a comment above each saying why. + [string]$KnownFailures = '', + # Run only these codelabs. For reproducing one locally. + [string[]]$Only = @(), + # Per command, unless the sidecar gives one. A plugin download and a Go toolchain can both + # land in a single step. + [int]$TimeoutSeconds = 600, + # Execute nothing: check the bookkeeping and print the plan. + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# The codelabs chain commands with &&, which Windows PowerShell 5.1 rejects outright. A person on +# Windows today has pwsh 7, and so does windows-latest; running under 5.1 would report findings +# about a shell nobody should be using. +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "run_codelabs.ps1 needs PowerShell 7 (pwsh); this is $($PSVersionTable.PSVersion)" +} + +# Taken once, up front: each codelab points TEMP somewhere of its own, and GetTempPath follows it. +$temp = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } +if (-not $Logs) { $Logs = Join-Path $temp 'logs' } +New-Item -ItemType Directory -Force -Path $Logs | Out-Null +$problems = [Collections.Generic.List[string]]::new() + +function Write-Summary([string] $Text) { + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $Text } + else { Write-Host $Text } +} + +# The same format, and the same rules, as run_native_tests.ps1's. +function Read-KnownFailures([string] $Path) { + $known = @{} + if (-not $Path -or -not (Test-Path $Path)) { return $known } + foreach ($line in Get-Content $Path) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } + $known[$trimmed] = $true + } + return $known +} + +function Get-LogName([string] $Key) { return ($Key -replace '[^A-Za-z0-9_-]+', '_') + '.log' } + +# --- what a step needs, decided on this machine ---------------------------------------------- + +# Presence alone is not the question. windows-latest has a docker, but it runs Windows containers, +# and every image in the codelabs is Linux; and it has a kubectl, with no cluster behind it. Either +# would pass a Get-Command check and then fail for a reason that says nothing about the codelab. +$needCache = @{} +function Test-Need([string] $Need) { + if ($needCache.ContainsKey($Need)) { return $needCache[$Need] } + $result = switch ($Need) { + 'docker' { + if (-not (Get-Command docker -EA SilentlyContinue)) { 'docker is not installed' } + else { + $os = (& docker info --format '{{.OSType}}' 2>$null | Out-String).Trim() + if ($os -ne 'linux') { "docker runs $(if ($os) { $os } else { 'no' }) containers, and the codelab's images are Linux" } + else { '' } + } + } + 'kubectl' { + if (-not (Get-Command kubectl -EA SilentlyContinue)) { 'kubectl is not installed' } + else { + & kubectl cluster-info --request-timeout=5s *> $null + if ($LASTEXITCODE -ne 0) { 'kubectl has no cluster to talk to' } else { '' } + } + } + default { $null } + } + $needCache[$Need] = $result + return $result +} + +# --- running one command -------------------------------------------------------------------- + +function Invoke-Command-Step($Step, [string] $WorkDir, [string] $LogPath) { + $timeout = if ($Step.PSObject.Properties['timeout'] -and $Step.timeout) { $Step.timeout } else { $TimeoutSeconds } + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Step.command)) + $proc = Start-Process -FilePath $script:Pwsh ` + -ArgumentList @('-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', $encoded) ` + -WorkingDirectory $WorkDir -NoNewWindow -PassThru ` + -RedirectStandardOutput "$LogPath.out" -RedirectStandardError "$LogPath.err" ` + -RedirectStandardInput $script:EmptyInput + $timedOut = -not $proc.WaitForExit($timeout * 1000) + if ($timedOut) { + # The whole tree: a plz that has started a build action leaves children behind, and a + # child holding plz-out open stops the next codelab's directory being removed. + try { $proc.Kill($true) } catch { } + $proc.WaitForExit() + } + # ExitCode is not always populated until the object is refreshed, and a null would read as a + # pass. See run_native_probes.ps1. + $proc.Refresh() + $output = @(Get-Content -LiteralPath "$LogPath.out", "$LogPath.err" -EA SilentlyContinue) + Set-Content -LiteralPath $LogPath -Value (@("> $($Step.command)", " in $WorkDir", '') + $output) + Remove-Item -LiteralPath "$LogPath.out", "$LogPath.err" -EA SilentlyContinue + return [pscustomobject]@{ + ExitCode = if ($timedOut) { $null } elseif ($null -ne $proc.ExitCode) { $proc.ExitCode } else { 0 } + TimedOut = $timedOut + Timeout = $timeout + Output = $output + } +} + +# Merges a .plzconfig fragment the way a reader following the codelab edits the file: a key the +# section already has is replaced, a new key goes into its section, and a new section is appended. +# +# Appending the fragment verbatim was tried first, and it manufactured a failure on the first +# native run: plz init plugin go already writes GoTool, the codelab's fragment sets it again, and a +# plugin section refuses a repeated key where core config quietly takes the last one. A key repeated +# on purpose to extend a list would be replaced here rather than added to; no codelab fragment has +# one. +function Merge-PlzConfig([string] $Existing, [string] $Fragment) { + $lines = [Collections.Generic.List[string]]::new() + if ($Existing) { $lines.AddRange([string[]]($Existing.TrimEnd("`r", "`n") -split "`r?`n")) } + + # Section names are case-insensitive in this format; a subsection's quoted name is not. + function Get-SectionKey([string] $Header) { + if ($Header -notmatch '^\s*\[\s*([^\s"\]]+)\s*(?:"([^"]*)")?\s*\]') { return $null } + return "$($Matches[1].ToLowerInvariant())|$($Matches[2])" + } + # Where a section starts, and the index after its last non-blank line. + function Find-Section([string] $Key) { + for ($i = 0; $i -lt $lines.Count; $i++) { + # -cne: PowerShell compares case-insensitively by default, and the subsection half + # of the key must not be. + if ((Get-SectionKey $lines[$i]) -cne $Key) { continue } + $end = $i + 1 + for ($j = $i + 1; $j -lt $lines.Count -and -not $lines[$j].TrimStart().StartsWith('['); $j++) { + if ($lines[$j].Trim()) { $end = $j + 1 } + } + return @($i, $end) + } + return $null + } + + $section = $null + foreach ($raw in ($Fragment -split "`r?`n")) { + $line = $raw.Trim() + if (-not $line -or $line.StartsWith(';') -or $line.StartsWith('#')) { continue } + $key = Get-SectionKey $line + if ($key) { + $section = $key + if (-not (Find-Section $key)) { + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1].Trim()) { $lines.Add('') } + $lines.Add($line) + } + continue + } + if (-not $section -or $line -notmatch '^([^=;#]+?)\s*=') { continue } + $name = $Matches[1].Trim() + $start, $end = Find-Section $section + $replaced = $false + for ($i = $start + 1; $i -lt $end; $i++) { + if ($lines[$i] -match '^\s*([^=;#]+?)\s*=' -and $Matches[1].Trim() -ieq $name) { + $lines[$i] = $line + $replaced = $true + break + } + } + if (-not $replaced) { $lines.Insert($end, $line) } + } + return ($lines -join "`n") + "`n" +} + +# How much of what the codelab shows this command printing actually appeared. Advisory only. +function Compare-Expected($Step, $Output) { + if (-not $Step.PSObject.Properties['expected_output'] -or -not $Step.expected_output) { return '' } + $got = ($Output | ForEach-Object { $_.Trim() }) -join "`n" + $missing = @($Step.expected_output | Where-Object { $_.Trim() -and -not $got.Contains($_.Trim()) }) + if ($missing.Count -eq 0) { return '' } + return "$($missing.Count) of $(@($Step.expected_output).Count) lines the codelab shows did not appear (advisory)" +} + +# --- setup ---------------------------------------------------------------------------------- + +$planDoc = Get-Content -Raw -LiteralPath $Plan | ConvertFrom-Json +$known = Read-KnownFailures $KnownFailures + +# An entry naming nothing is also caught on Linux, by //test/windows/codelab_script/script:script_test. +# Checked again here so that a plan and a failures list from different commits cannot pass quietly. +$planNames = @{} +foreach ($c in $planDoc.codelabs) { + $planNames[$c.id] = $true + foreach ($s in $c.steps) { $planNames[$s.key] = $true } +} +foreach ($k in $known.Keys) { + if (-not $planNames.ContainsKey($k)) { + $problems.Add("$k is in $KnownFailures but names nothing in the plan") + } +} + +if (-not $DryRun) { + if (-not $Release) { throw '-Release is required unless -DryRun is given' } + $zip = Get-ChildItem -Path $Release -Filter 'please_*.zip' | Select-Object -First 1 + if (-not $zip) { throw "No please_*.zip in $Release" } + $install = Join-Path $temp 'codelab-install' + if (Test-Path $install) { Remove-Item -Recurse -Force $install } + Expand-Archive -Path $zip.FullName -DestinationPath $install + $pleaseDir = Join-Path $install 'please' + if (-not (Test-Path (Join-Path $pleaseDir 'plz.cmd'))) { throw "No plz.cmd in $($zip.Name)" } + + # On the PATH, not invoked by path. The codelabs say `plz`, package/Install.md tells a Windows + # user to put this directory on their PATH, and doing the same here is also the only thing + # anywhere that runs plz.cmd natively. If it mangles arguments, that is a finding. + $env:PATH = "$pleaseDir$([IO.Path]::PathSeparator)$env:PATH" + $script:Pwsh = (Get-Process -Id $PID).Path + $script:EmptyInput = Join-Path $temp 'codelab-empty-stdin' + Set-Content -LiteralPath $script:EmptyInput -Value $null -NoNewline + + # Through the PATH, as every codelab step will be. If plz.cmd cannot even report a version, + # say so in one line rather than as a stack trace, and let the codelabs show how far it gets. + Write-Host '::group::plz --version' + try { + & plz --version 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { $problems.Add("plz --version exited $LASTEXITCODE through the PATH") } + } catch { + $problems.Add("plz --version could not run through the PATH: $_") + } + Write-Host '::endgroup::' +} + +$utf8 = [Text.UTF8Encoding]::new($false) +$rows = [Collections.Generic.List[object]]::new() +$details = [Collections.Generic.List[string]]::new() + +# --- the codelabs --------------------------------------------------------------------------- + +foreach ($codelab in $planDoc.codelabs) { + if ($Only.Count -gt 0 -and $codelab.id -notin $Only) { continue } + $counts = [ordered]@{ PASS = 0; FAIL = 0; KNOWN = 0; SKIPPED = 0; BLOCKED = 0 } + $total = if ($codelab.blocks.PSObject.Properties['total']) { $codelab.blocks.total } else { 0 } + + if ($codelab.PSObject.Properties['not_runnable'] -and $codelab.not_runnable) { + $rows.Add([pscustomobject]@{ Id = $codelab.id; Blocks = $total; Steps = 0; Counts = $counts; Note = "not runnable: $($codelab.not_runnable)" }) + continue + } + + Write-Host "::group::$($codelab.id) - $($codelab.title)" + $root = Join-Path $temp "codelabs\$($codelab.id)" + $home_ = Join-Path $temp "codelabs\$($codelab.id)-home" + + if (-not $DryRun) { + foreach ($d in $root, $home_) { + if (Test-Path $d) { Remove-Item -Recurse -Force $d } + } + New-Item -ItemType Directory -Force -Path $root, "$home_\AppData\Local", "$home_\Temp" | Out-Null + # A home of its own, beside the working tree rather than in it, so ~/.please and the + # caches land somewhere a `tree -a` does not see. LOCALAPPDATA is not optional: Please's + # content-addressed cache has produced false passes twice in this port, and here it would + # let one codelab replay what an earlier one built. Nothing above $root holds a .plzconfig, + # which is what keeps plz init from stopping to ask whether to continue. + $env:HOME = $home_ + $env:USERPROFILE = $home_ + $env:LOCALAPPDATA = "$home_\AppData\Local" + $env:TEMP = "$home_\Temp" + $env:TMP = "$home_\Temp" + } + + $cwd = $root + $blockedBy = '' + $ran = 0 + + foreach ($step in $codelab.steps) { + $key = $step.key + $log = Join-Path $Logs (Get-LogName $key) + $outcome = '' + $note = '' + + if ($step.kind -eq 'skip') { + $outcome = 'SKIPPED' + $note = "$($step.reason): $($step.detail)" + } elseif ($blockedBy) { + $outcome = 'BLOCKED' + $note = "after $blockedBy" + } elseif ($DryRun) { + $what = switch ($step.kind) { + 'run' { $step.command } + 'file' { "$($step.mode) $($step.path)" } + 'chdir' { "cd $($step.dir)" } + } + Write-Host (" {0,-60} {1,-5} {2}" -f $key, $step.kind, $what) + if ($step.kind -eq 'run') { $ran++ } + continue + } else { + switch ($step.kind) { + 'chdir' { + $target = Join-Path $cwd $step.dir + if (Test-Path -LiteralPath $target -PathType Container) { + $cwd = (Resolve-Path -LiteralPath $target).Path + $outcome = 'PASS' + } else { + # A reader whose earlier step did not create it is stuck here too. + $outcome = 'FAIL' + $note = "no directory $($step.dir) in $cwd" + } + } + 'file' { + $path = Join-Path $cwd $step.path + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null + # Not Set-Content: its encoding differs between PowerShell versions, and a + # .plzconfig carrying a byte-order mark does not parse. + if ($step.mode -eq 'merge') { + $existing = if (Test-Path -LiteralPath $path) { [IO.File]::ReadAllText($path) } else { '' } + [IO.File]::WriteAllText($path, (Merge-PlzConfig $existing $step.content), $utf8) + } else { + [IO.File]::WriteAllText($path, "$($step.content)`n", $utf8) + } + $outcome = 'PASS' + } + 'run' { + $missing = @() + if ($step.PSObject.Properties['needs'] -and $step.needs) { + foreach ($need in $step.needs) { + $why = Test-Need $need + if ($null -eq $why) { + $problems.Add("${key}: unknown need '$need'; run_codelabs.ps1 has no way to check for it") + $missing += "unknown need $need" + } elseif ($why) { + $missing += $why + } + } + } + if ($missing.Count -gt 0) { + $outcome = 'SKIPPED' + $note = "tool-missing: $($missing -join '; ')" + break + } + Write-Host "> $($step.command)" + $ran++ + $r = Invoke-Command-Step $step $cwd $log + $r.Output | Select-Object -Last 40 | Write-Host + $assert = if ($step.PSObject.Properties['assert']) { $step.assert } else { '' } + if ($r.TimedOut) { + $outcome = 'FAIL' + $note = "timed out after $($r.Timeout)s" + } elseif ($r.ExitCode -ne 0) { + $outcome = 'FAIL' + $note = "exited $($r.ExitCode)" + } elseif ($assert -and -not (($r.Output -join "`n").Contains($assert))) { + $outcome = 'FAIL' + $note = "exited 0 but did not print '$assert'" + } else { + $outcome = 'PASS' + $note = Compare-Expected $step $r.Output + } + } + } + } + + if ($outcome -eq 'FAIL' -and ($known.ContainsKey($key) -or $known.ContainsKey($codelab.id))) { + $outcome = 'KNOWN' + } + if ($outcome -eq 'PASS' -and $known.ContainsKey($key)) { + $problems.Add("$key is in $KnownFailures but passed; remove it") + } + $nonBlocking = $step.PSObject.Properties['non_blocking'] -and $step.non_blocking + if ($outcome -in 'FAIL', 'KNOWN' -and -not $nonBlocking) { + $blockedBy = $key + } + if ($outcome -eq 'FAIL') { + $problems.Add("$key $note") + } + + $counts[$outcome]++ + if ($outcome -ne 'PASS' -or $note) { + $details.Add("$outcome $key$(if ($note) { " - $note" })") + } + Write-Host "$outcome $key$(if ($note) { " - $note" })" + } + + # The analogue of run_native_tests.ps1's "the bundle produced no runnable tests". A codelab that + # ran nothing and was not declared unrunnable has had its commands lost somewhere between the + # Markdown and here, and reporting it as a clean pass would be the worst possible answer. + if ($ran -eq 0 -and $counts.BLOCKED -eq 0 -and $counts.SKIPPED -eq 0) { + $problems.Add("$($codelab.id) ran no commands, and codelab_steps.conf does not say it has none to run") + } + if ($known.ContainsKey($codelab.id) -and $counts.KNOWN -eq 0 -and -not $DryRun) { + $problems.Add("$($codelab.id) is in $KnownFailures but nothing in it failed; remove it") + } + Write-Host '::endgroup::' + $rows.Add([pscustomobject]@{ Id = $codelab.id; Blocks = $total; Steps = @($codelab.steps).Count; Counts = $counts; Note = '' }) +} + +# --- report --------------------------------------------------------------------------------- + +Write-Summary "## Codelabs on Windows`n" +if ($DryRun) { Write-Summary "Dry run: nothing was executed.`n" } +Write-Summary '| Codelab | Blocks | Steps | Passed | Failed | Known | Skipped | Blocked | |' +Write-Summary '|---|---:|---:|---:|---:|---:|---:|---:|---|' +foreach ($r in $rows) { + $c = $r.Counts + Write-Summary "| $($r.Id) | $($r.Blocks) | $($r.Steps) | $($c.PASS) | $($c.FAIL) | $($c.KNOWN) | $($c.SKIPPED) | $($c.BLOCKED) | $($r.Note) |" +} +if ($details.Count -gt 0) { + # Printed as the step key first so a line can go straight into codelab_known_failures.txt. + Write-Summary "`n
    Every step that did not simply pass`n" + Write-Summary '```' + foreach ($d in $details) { Write-Summary $d } + Write-Summary '```' + Write-Summary '
    ' +} + +if ($problems.Count -gt 0) { + Write-Summary "`n### Problems`n" + foreach ($p in $problems) { Write-Summary "- $p" } + Write-Host "`n$($problems.Count) problem(s):" + foreach ($p in $problems) { Write-Host " $p" } + exit 1 +} +Write-Host "`nNo unexpected results." diff --git a/test/windows/run_native_probes.ps1 b/test/windows/run_native_probes.ps1 new file mode 100644 index 0000000000..64f77e7674 --- /dev/null +++ b/test/windows/run_native_probes.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + Runs the Windows release against a test repo, and probes the things Wine cannot show. + +.DESCRIPTION + The unit tests in the bundle cover Please's own code. This covers the release as an + artifact, and the failure classes docs/design/windows/05-testing-strategy.md lists as + invisible under Wine: files held open on teardown, and path length. + + Case-insensitivity and the symlink copy fallback are deliberately not here. They belong in + Go tests in src/fs, where they ride the bundle and are written in the language the fix will + be written in. +#> +param( + # A directory holding the release zip. + [Parameter(Mandatory)][string]$Release, + [string]$Logs = "$env:RUNNER_TEMP\logs", + # How many times to build and clean in a row. One build does not find a sharing violation. + [int]$Rebuilds = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path "$PSScriptRoot\..\..").Path +New-Item -ItemType Directory -Force -Path $Logs | Out-Null +$problems = @() + +function Write-Summary([string] $Text) { + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $Text } + else { Write-Host $Text } +} + +function Invoke-Plz([string] $WorkDir, [string[]] $PlzArgs, [string] $LogName) { + $log = Join-Path $Logs $LogName + Push-Location $WorkDir + try { + # Not the repository checkout: .plzconfig_windows_amd64 names MinGW tools that are not + # on this machine, and a native plz reads it. The fixtures carry their own config. + $proc = Start-Process -FilePath $script:PleaseExe -ArgumentList $PlzArgs ` + -NoNewWindow -PassThru -RedirectStandardOutput "$log.out" -RedirectStandardError "$log.err" + $proc.WaitForExit() + $proc.Refresh() + Get-Content -LiteralPath "$log.out", "$log.err" -EA SilentlyContinue | Set-Content -LiteralPath $log + Get-Content -LiteralPath $log | Write-Host + # Start-Process does not always populate ExitCode until the object is refreshed, and + # a null here would read as a failure. + if ($null -ne $proc.ExitCode) { return $proc.ExitCode } + return 0 + } finally { Pop-Location } +} + +# --- the release itself ------------------------------------------------------------------- + +$zip = Get-ChildItem -Path $Release -Filter 'please_*.zip' | Select-Object -First 1 +if (-not $zip) { throw "No please_*.zip in $Release" } +$install = Join-Path $env:RUNNER_TEMP 'install' +if (Test-Path $install) { Remove-Item -Recurse -Force $install } +Expand-Archive -Path $zip.FullName -DestinationPath $install +$script:PleaseExe = Join-Path $install 'please\please.exe' +if (-not (Test-Path $script:PleaseExe)) { throw "No please.exe in $($zip.Name)" } + +Write-Host "::group::plz --version" +$version = & $script:PleaseExe --version 2>&1 | Out-String +Write-Host $version +Write-Host '::endgroup::' +Write-Summary "## Windows release`n`n``$($version.Trim())`` from ``$($zip.Name)```n" + +# --- a real build, compared byte for byte --------------------------------------------------- + +$work = Join-Path $env:RUNNER_TEMP 'smoke' +if (Test-Path $work) { Remove-Item -Recurse -Force $work } +Copy-Item -Recurse (Join-Path $repoRoot 'test\windows\smoke_repo') $work + +Write-Host "::group::build //:pipeline" +$code = Invoke-Plz $work @('build', '//:pipeline') 'smoke_build.log' +Write-Host '::endgroup::' +if ($code -ne 0) { + $problems += "building //:pipeline exited $code" +} else { + # Compared line by line rather than as bytes: the build action's output is whatever busybox + # wrote, and the expectation came out of git, so only the content is meant to match. + $got = Get-Content (Join-Path $work 'plz-out\gen\sorted.txt') + $want = Get-Content (Join-Path $work 'expected_sorted.txt') + if (Compare-Object $got $want) { + $problems += "//:pipeline produced $($got -join ',') rather than $($want -join ',')" + } +} + +# --- files held open on teardown ------------------------------------------------------------ + +# Windows refuses to delete or rename a file another process has open, and Wine is more +# permissive. This is the single most likely source of real-Windows-only failures, and it hits +# where Please works hardest: plz-out/tmp teardown and RemoveAll. One build never finds it; +# repetition under a live virus scanner sometimes does. +Write-Host "::group::$Rebuilds builds with a clean between each" +for ($i = 1; $i -le $Rebuilds; $i++) { + $code = Invoke-Plz $work @('clean') "clean_$i.log" + if ($code -ne 0) { $problems += "plz clean exited $code on run $i" } + $code = Invoke-Plz $work @('build', '//:pipeline') "rebuild_$i.log" + if ($code -ne 0) { $problems += "rebuild $i exited $code" } +} +Write-Host '::endgroup::' + +# --- long paths ----------------------------------------------------------------------------- + +# MAX_PATH is 260 unless long-path support is on and the binary opted in by manifest. Go +# prefixes absolute paths with \\?\ by itself, so the interesting failure is not in Please but +# in what it hands busybox as a command line, which gets no such treatment - which is exactly +# the pipe-and-redirect action this fixture builds. +$padding = 'w' * 60 +$deep = Join-Path $env:RUNNER_TEMP "long\$padding\$padding\$padding" +if (Test-Path (Join-Path $env:RUNNER_TEMP 'long')) { + Remove-Item -Recurse -Force (Join-Path $env:RUNNER_TEMP 'long') +} +New-Item -ItemType Directory -Force -Path $deep | Out-Null +$deepRepo = Join-Path $deep 'repo' +Copy-Item -Recurse (Join-Path $repoRoot 'test\windows\smoke_repo') $deepRepo +Write-Host "::group::build at a $($deepRepo.Length)-character path" +$code = Invoke-Plz $deepRepo @('build', '//:pipeline') 'long_path.log' +Write-Host '::endgroup::' +if ($code -ne 0) { + # Recorded rather than fatal on the first pass: whether this is expected to work depends on + # LongPathsEnabled, which the workflow prints. + $problems += "building at a $($deepRepo.Length)-character path exited $code" +} + +# --- report --------------------------------------------------------------------------------- + +if ($problems.Count -gt 0) { + Write-Summary "`n### Probe failures`n" + foreach ($p in $problems) { Write-Summary "- $p" } + Write-Host "`n$($problems.Count) probe failure(s):" + foreach ($p in $problems) { Write-Host " $p" } + exit 1 +} +Write-Summary "`nThe release built a repo, survived $Rebuilds clean-and-rebuild cycles, and built at a long path." +Write-Host "`nAll probes passed." diff --git a/test/windows/run_native_tests.ps1 b/test/windows/run_native_tests.ps1 new file mode 100644 index 0000000000..07e838da96 --- /dev/null +++ b/test/windows/run_native_tests.ps1 @@ -0,0 +1,183 @@ +<# +.SYNOPSIS + Runs the cross-built Windows test binaries natively, out of the bundle. + +.DESCRIPTION + The counterpart of wine_go_test for a real Windows machine. //test/windows:native_test_bundle + packages the same test binaries and the same data that run under Wine on Linux; this runs + them here, where the answers actually count. See docs/design/windows/05-testing-strategy.md + for what Wine cannot show and why that matters. + + Everything this sets up mirrors what wine_go_test sets up, minus Wine: the test's own + directory as the working directory, $DATA pointing at its data, and the bundled busybox on + the PATH for the tests that run build actions. +#> +param( + # The extracted bundle: manifest.txt, shell/, tests//. + [Parameter(Mandatory)][string]$Bundle, + [string]$Logs = "$env:RUNNER_TEMP\logs", + # One "name" or "name::TestCase" per line, with a comment above each saying why. A known + # failure that starts passing is also a failure, which is what stops this becoming a + # dumping ground. + [string]$KnownFailures = '', + # Run only these entries. For reproducing one failure locally. + [string[]]$Only = @(), + [int]$TimeoutSeconds = 600 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Read-KnownFailures([string] $Path) { + $known = @{} + if (-not $Path -or -not (Test-Path $Path)) { return $known } + foreach ($line in Get-Content $Path) { + $trimmed = $line.Trim() + if (-not $trimmed -or $trimmed.StartsWith('#')) { continue } + $known[$trimmed] = $true + } + return $known +} + +# Go's -test.v marks each case with a line like "--- FAIL: TestFoo (0.05s)". Subtests come +# through the same way, indented, which is why the pattern allows leading whitespace. +function Get-Cases([string] $Path) { + $cases = @() + foreach ($line in Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue) { + if ($line -match '^\s*--- (PASS|FAIL|SKIP): (\S+)') { + $cases += [pscustomobject]@{ Result = $Matches[1]; Name = $Matches[2] } + } + } + return $cases +} + +function Write-Summary([string] $Text) { + if ($env:GITHUB_STEP_SUMMARY) { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $Text } + else { Write-Host $Text } +} + +$bundleDir = (Resolve-Path $Bundle).Path +$manifest = Join-Path $bundleDir 'manifest.txt' +if (-not (Test-Path $manifest)) { throw "No manifest.txt in $bundleDir; is that the bundle?" } +New-Item -ItemType Directory -Force -Path $Logs | Out-Null + +$known = Read-KnownFailures $KnownFailures +$names = Get-Content $manifest | Where-Object { $_.Trim() } +if ($Only.Count -gt 0) { $names = $names | Where-Object { $Only -contains $_ } } + +$rows = @() +$problems = @() + +foreach ($name in $names) { + $dir = Join-Path $bundleDir "tests\$name" + if (-not (Test-Path $dir)) { + # The manifest is written at parse time and the directories at build time, so this + # means a test was dropped between the two rather than that it failed. + $problems += "$name is in manifest.txt but has no directory in the bundle" + continue + } + + Write-Host "::group::$name" + Push-Location $dir + try { + # Please rewrites every backslash in every environment value on Windows + # (BuildEnv.normalisePathSeparators), so hand these over already normalised. Getting it + # wrong produces failures that look like port bugs and are not. + $here = $dir -replace '\\', '/' + foreach ($v in 'TEST_DIR', 'TMP_DIR', 'TMPDIR', 'HOME', 'USERPROFILE', 'TEMP', 'TMP') { + Set-Item -Path "env:$v" -Value $here + } + # Not something Please itself redirects, but without it a test that runs a build shares + # the machine's directory cache, and a cached artifact from another run is exactly how + # this port has produced a false pass before. + $env:LOCALAPPDATA = $here + + $dataFile = Join-Path $dir 'DATA.txt' + $env:DATA = if (Test-Path $dataFile) { (Get-Content -Raw $dataFile).Trim() } else { '' } + + if (Test-Path (Join-Path $dir 'NEEDS_SHELL')) { + # Its own directory on the PATH rather than the working directory, which is how an + # install has it and what Go's exec will agree to run. + $env:PATH = (Join-Path $bundleDir 'shell') + [IO.Path]::PathSeparator + $env:PATH + } + + $out = Join-Path $Logs "$name.out" + $err = Join-Path $Logs "$name.err" + $proc = Start-Process -FilePath (Join-Path $dir 'test.exe') ` + -ArgumentList '-test.v' -NoNewWindow -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { + # A hung test would otherwise hold the job open for hours. Kill the tree: these + # binaries start children of their own. + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + $problems += "$name timed out after ${TimeoutSeconds}s" + $rows += [pscustomobject]@{ Test = $name; Pass = 0; Fail = 0; Skip = 0; Status = 'TIMEOUT' } + continue + } + # Start-Process does not always populate ExitCode until the object is refreshed, and + # a null here would read as non-zero and fail a test that passed. + $proc.Refresh() + $code = if ($null -ne $proc.ExitCode) { $proc.ExitCode } else { 0 } + + # Panics go to stderr and belong with the output they interrupted. + $log = Join-Path $Logs "$name.log" + Get-Content -LiteralPath $out, $err -ErrorAction SilentlyContinue | Set-Content -LiteralPath $log + Get-Content -LiteralPath $log | Write-Host + + $cases = Get-Cases $log + $failed = @($cases | Where-Object { $_.Result -eq 'FAIL' }) + $passed = @($cases | Where-Object { $_.Result -eq 'PASS' }) + $skipped = @($cases | Where-Object { $_.Result -eq 'SKIP' }) + + foreach ($case in $failed) { + $key = "$name::$($case.Name)" + if ($known.ContainsKey($key) -or $known.ContainsKey($name)) { continue } + $problems += $key + Write-Host "::error title=$name::$($case.Name) failed" + } + foreach ($case in $passed) { + $key = "$name::$($case.Name)" + if ($known.ContainsKey($key)) { + $problems += "$key is in $KnownFailures but passed; remove it" + } + } + # A binary that dies without reporting a single case - a panic in TestMain, a missing + # DLL - would otherwise look like a clean run with nothing in it. + if ($code -ne 0 -and $failed.Count -eq 0 -and -not $known.ContainsKey($name)) { + $problems += "$name exited $code with no failing case; see $name.log" + Write-Host "::error title=$name::exited $code without reporting a failure" + } + + $status = if ($failed.Count -gt 0 -or $code -ne 0) { 'FAIL' } else { 'ok' } + $rows += [pscustomobject]@{ + Test = $name; Pass = $passed.Count; Fail = $failed.Count + Skip = $skipped.Count; Status = $status + } + } finally { + Pop-Location + Write-Host '::endgroup::' + } +} + +Write-Summary "## Windows unit tests`n" +Write-Summary '| Test | Pass | Fail | Skip | |' +Write-Summary '|---|---:|---:|---:|---|' +foreach ($row in $rows) { + Write-Summary "| $($row.Test) | $($row.Pass) | $($row.Fail) | $($row.Skip) | $($row.Status) |" +} +if ($rows.Count -gt 0) { + $totals = $rows | Measure-Object -Property Pass, Fail, Skip -Sum + Write-Summary "`n$($rows.Count) binaries, $($totals[0].Sum) passed, $($totals[1].Sum) failed, $($totals[2].Sum) skipped." +} else { + Write-Summary "`nNo test binaries ran at all." + $problems += 'the bundle produced no runnable tests' +} + +if ($problems.Count -gt 0) { + Write-Summary "`n### Unexpected`n" + foreach ($p in $problems) { Write-Summary "- $p" } + Write-Host "`n$($problems.Count) unexpected result(s):" + foreach ($p in $problems) { Write-Host " $p" } + exit 1 +} +Write-Host "`nAll $($rows.Count) test binaries behaved as expected." diff --git a/test/windows/shell/BUILD b/test/windows/shell/BUILD new file mode 100644 index 0000000000..1970960f91 --- /dev/null +++ b/test/windows/shell/BUILD @@ -0,0 +1,18 @@ +# The subject of //test/windows:sh_binary_test. Built for windows_amd64 and run under Wine; +# nothing builds these here, hence the manual labels. +sh_library( + name = "lib", + src = "lib.sh", + labels = ["manual"], +) + +# On Windows this comes out as greet.cmd: a batch preamble that unpacks the zip appended to it +# and hands the script to the bundled busybox. It sources the library out of the unpacked +# payload, takes an argument and exits non-zero, so the wrapper has to get all three right. +sh_binary( + name = "greet", + labels = ["manual"], + main = "greet.sh", + visibility = ["//test/windows:all"], + deps = [":lib"], +) diff --git a/test/windows/shell/greet.sh b/test/windows/shell/greet.sh new file mode 100644 index 0000000000..84df506927 --- /dev/null +++ b/test/windows/shell/greet.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# The subject of //test/windows:sh_binary_test. Sources a library that only exists because the +# payload was unpacked, and finds it relative to $0, which has to be the file that was run +# rather than this one - the same arrangement as on Unix, where they are one file. +set -eu + +lib="$(dirname "$0")/test/windows/shell/lib.sh" +. "$lib" + +echo "$GREETING $1" + +# Scribble on the unpacked library, so that a second run in the same directory has to replace +# it. Anything the payload leaves behind is a build output and so read-only, and a read-only +# file on Windows cannot be written or replaced at all - which would show up here as either +# this line failing or the next run reading the wrong greeting. +echo 'GREETING="stale"' > "$lib" + +exit "$2" diff --git a/test/windows/shell/lib.sh b/test/windows/shell/lib.sh new file mode 100644 index 0000000000..318e2f67a8 --- /dev/null +++ b/test/windows/shell/lib.sh @@ -0,0 +1 @@ +GREETING="hello from" diff --git a/test/windows/smoke_repo/.plzconfig b/test/windows/smoke_repo/.plzconfig new file mode 100644 index 0000000000..abbbc77ec8 --- /dev/null +++ b/test/windows/smoke_repo/.plzconfig @@ -0,0 +1,9 @@ +; Named BUILD_FILE so the outer repo doesn't parse this as one of its own packages. +[parse] +BuildFileName = BUILD_FILE + +; No dir cache. Please's cache is content-addressed and lands outside the test's tmp dir, so +; leaving it on lets a run replay artifacts an earlier, differently-built binary produced - +; which has already caused one false pass during this port. +[cache] +dir = diff --git a/test/windows/smoke_repo/BUILD_FILE b/test/windows/smoke_repo/BUILD_FILE new file mode 100644 index 0000000000..d65edf8bc7 --- /dev/null +++ b/test/windows/smoke_repo/BUILD_FILE @@ -0,0 +1,11 @@ +# The shell smoke test from docs/design/windows/02-shell-and-build-actions.md: a pipe, a +# redirect and two applets in one build action. +genrule( + name = "pipeline", + srcs = [ + "a.txt", + "b.txt", + ], + outs = ["sorted.txt"], + cmd = "cat $SRCS | sort > $OUT", +) diff --git a/test/windows/smoke_repo/a.txt b/test/windows/smoke_repo/a.txt new file mode 100644 index 0000000000..0ae7ef4fca --- /dev/null +++ b/test/windows/smoke_repo/a.txt @@ -0,0 +1,2 @@ +delta +bravo diff --git a/test/windows/smoke_repo/b.txt b/test/windows/smoke_repo/b.txt new file mode 100644 index 0000000000..8b5861158d --- /dev/null +++ b/test/windows/smoke_repo/b.txt @@ -0,0 +1,2 @@ +charlie +alpha diff --git a/test/windows/smoke_repo/expected_sorted.txt b/test/windows/smoke_repo/expected_sorted.txt new file mode 100644 index 0000000000..bfdfbcdb37 --- /dev/null +++ b/test/windows/smoke_repo/expected_sorted.txt @@ -0,0 +1,4 @@ +alpha +bravo +charlie +delta diff --git a/third_party/binary/BUILD b/third_party/binary/BUILD index 0c6b12eb4e..cf57e01bdc 100644 --- a/third_party/binary/BUILD +++ b/third_party/binary/BUILD @@ -20,3 +20,43 @@ remote_file( binary = True, url = f"https://github.com/please-build/puku/releases/download/v{PUKU_VERSION}/puku-{PUKU_VERSION}-{CONFIG.OS}_{CONFIG.ARCH}", ) + +# The shell and coreutils that build actions run in on Windows. Windows ships nothing that can +# execute a build action, so Please bundles this rather than requiring an install; see +# docs/design/windows/02-shell-and-build-actions.md. +# +# This is busybox-w64, a third-party fork of busybox (https://frippery.org/busybox/), not an +# upstream busybox release. It is GPL-2.0, so the release notes it separately. +BUSYBOX_VERSION = "FRP-6075-g169694ebd" + +remote_file( + name = "busybox", + out = "busybox.exe", + binary = True, + hashes = ["07bb1e5b095b00d68a695481f9240879f33c5724b40aa2308f999d54ed78f075"], + licences = ["GPL-2.0"], + url = f"https://frippery.org/files/busybox/busybox-w64-{BUSYBOX_VERSION}.exe", + visibility = [ + "//package:all", + "//test/windows:all", + ], +) + +# A Python for the Wine tests to run .pex files with. This is the embeddable package from +# python.org: python.exe, its DLLs and a zipped standard library, with nothing to install. +# +# It is here rather than as a toolchain because that is all it is for. Nothing Please builds +# depends on it, and no part of the Windows release ships it - a user brings their own Python, +# the same as on any other platform. +PYTHON_WINDOWS_VERSION = "3.11.9" + +remote_file( + name = "python-windows", + out = "python-windows", + extract = True, + hashes = ["009d6bf7e3b2ddca3d784fa09f90fe54336d5b60f0e0f305c37f400bf83cfd3b"], + licences = ["PSF-2.0"], + test_only = True, + url = f"https://www.python.org/ftp/python/{PYTHON_WINDOWS_VERSION}/python-{PYTHON_WINDOWS_VERSION}-embed-amd64.zip", + visibility = ["//test/windows:all"], +) diff --git a/third_party/go/BUILD b/third_party/go/BUILD index 7fbb6b9cad..405ed2b435 100644 --- a/third_party/go/BUILD +++ b/third_party/go/BUILD @@ -374,6 +374,26 @@ go_repo( version = "v0.0.0-20210914205149-d1177395e3b8", ) +# arcat, and the two of its requirements nothing else here already pins. Please normally +# downloads a prebuilt arcat release keyed by platform, and there is none for Windows; building +# it from source is how the offline Windows release gets one. See +# docs/design/windows/08-offline-release.md. +go_repo( + module = "github.com/xi2/xz", + version = "v0.0.0-20171230120015-48954b6210f8", +) + +go_repo( + module = "github.com/please-build/ar", + version = "v0.0.0-20251128102243-20fe5956df94", +) + +go_repo( + module = "github.com/please-build/arcat", + patch = ["arcat_windows_rename.patch"], + version = "v1.3.1", +) + go_repo( module = "github.com/klauspost/cpuid/v2", version = "v2.4.0", diff --git a/third_party/go/arcat_windows_rename.patch b/third_party/go/arcat_windows_rename.patch new file mode 100644 index 0000000000..0614313bc3 --- /dev/null +++ b/third_party/go/arcat_windows_rename.patch @@ -0,0 +1,51 @@ +Two Windows fixes for arcat, both of which stop it producing a usable zip at all. + +1. The output file is created with ioutil.TempFile, which returns it open, and the handle is + never closed - zip.NewFile opens the same path again and closes only its own. Unix does not + care that a file being renamed is still open. Windows refuses: + + panic: Failed to rename output file: rename .\arcat-1624372107 ...: Sharing violation. + + which is every zip arcat is asked to write, so nothing needing one builds. + +2. Zip member names are always /-separated, and filepath.WalkDir hands back the OS separator. + On Windows every name added by a directory walk therefore carried backslashes, so --rename_dir + and --strip_prefix silently matched nothing and the members came out with names no reader + splits into directories. The rest of writer.go already assumes slashes - samePaths uses + path.IsAbs, not filepath.IsAbs. + +Carried here rather than upstream because there is nowhere to push it to yet. See +docs/design/windows/08-offline-release.md. + +--- a/main.go ++++ b/main.go +@@ -173,6 +173,9 @@ + tempFile, err := ioutil.TempFile(".", "arcat-") + must(err) + filename := tempFile.Name() ++ // Windows will not rename a file that is still open, and zip.NewFile opens this path ++ // again for itself, so this handle has no further use. ++ must(tempFile.Close()) + + f := zip.NewFile(filename, opts.Zip.Strict) + f.RenameDirs = opts.Zip.RenameDirs +--- a/zip/writer.go ++++ b/zip/writer.go +@@ -215,6 +215,10 @@ + if err != nil { + return err + } ++ // A zip member name is always /-separated, whatever the host separator is. Everything ++ // downstream of here - the rename and strip-prefix options, and the names written into ++ // the archive - depends on that. ++ path = filepath.ToSlash(path) + mode := entry.Type() + if path != f.input && ((mode & fs.ModeSymlink) == fs.ModeSymlink) { + if resolved, err := filepath.EvalSymlinks(path); err != nil { +@@ -303,7 +307,7 @@ + // AddFiles walks the given directory and adds any zip files (determined by suffix) that it finds within. + func (f *File) AddFiles(in string) error { +- f.input = in ++ f.input = filepath.ToSlash(in) + return filepath.WalkDir(in, f.walk) + } diff --git a/tools/images/build.sh b/tools/images/build.sh index f3b7c8316c..2affc7a5e2 100755 --- a/tools/images/build.sh +++ b/tools/images/build.sh @@ -5,7 +5,7 @@ set -euvo pipefail tag=$(date +%Y%m%d) reporoot=$(plz query reporoot) -images=("alpine" "freebsd_builder" "ubuntu" "ubuntu_alt") +images=("alpine" "freebsd_builder" "ubuntu" "ubuntu_alt" "windows_builder") for image in ${images[@]}; do cd "$reporoot/tools/images/$image" diff --git a/tools/images/windows_builder/Dockerfile b/tools/images/windows_builder/Dockerfile new file mode 100644 index 0000000000..b6cb7713fc --- /dev/null +++ b/tools/images/windows_builder/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:noble +LABEL org.opencontainers.image.authors="please thoughtmachine net" +LABEL org.opencontainers.image.source=https://github.com/thought-machine/please + +# A few miscellaneous dependencies. MinGW is here for the C/C++ rules, which cross-compile to +# Windows with it; the Go cross-build itself needs no C toolchain. Wine runs the results, which +# is the only way anything about the port's runtime behaviour gets checked before there is a +# Windows machine. It needs the i386 architecture enabled even for a 64-bit-only install. +RUN dpkg --add-architecture i386 && apt-get update && \ + apt-get install -y curl git gcc xz-utils g++-mingw-w64-x86-64 wine wine64 && apt-get clean + +# Go +RUN curl -fsSL https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz | tar -xzC /usr/local +RUN ln -s /usr/local/go/bin/go /usr/local/bin/go && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt +RUN GOOS=windows go install std + +WORKDIR /tmp diff --git a/tools/misc/gen_release.py b/tools/misc/gen_release.py index 40335a06e5..b8a8c214da 100755 --- a/tools/misc/gen_release.py +++ b/tools/misc/gen_release.py @@ -52,6 +52,7 @@ def __init__(self, github_token:str, dry_run:bool=False): self.known_content_types = { '.gz': 'application/gzip', '.xz': 'application/x-xz', + '.zip': 'application/zip', '.asc': 'text/plain', '.sha256': 'text/plain', } @@ -111,6 +112,8 @@ def _arch(self, artifact:str) -> str: return f'darwin_{cpu}' elif 'freebsd' in artifact: return f'freebsd_{cpu}' + elif 'windows' in artifact: + return f'windows_{cpu}' return f'linux_{cpu}' def sign_pgp(self, artifact:str) -> str: diff --git a/tools/misc/get_plz.ps1 b/tools/misc/get_plz.ps1 new file mode 100644 index 0000000000..c9a7adfad4 --- /dev/null +++ b/tools/misc/get_plz.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS + Downloads a precompiled copy of Please and installs it. + +.DESCRIPTION + The Windows counterpart of get_plz.sh, served from the same bucket and run the same way: + + irm https://get.please.build/get_plz.ps1 | iex + + Kept deliberately parallel to that script rather than clever, so the two can be read side by + side. The differences are all forced: the release is a .zip rather than a tarball, because + Windows has no guaranteed tar; the short name is a plz.cmd shim rather than a symlink, + because symlinks need Developer Mode; and the install is linked up a level by hard-linking + or copying, for the same reason. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# This fork publishes GitHub Releases rather than to the bucket upstream uses, because CircleCI +# only runs the publishing job on thought-machine/please. The asset names carry the platform, +# which is the layout gen_release.py already produces for the GitHub half of a release. +# +# Point PLZ_DOWNLOAD_BASE at 'https://get.please.build' to use the upstream bucket instead; the +# path shape differs, so the script picks the right one from the base. +$UrlBase = if ($env:PLZ_DOWNLOAD_BASE) { + $env:PLZ_DOWNLOAD_BASE +} else { + 'https://github.com/PeterNeiss/please/releases/download' +} + +if ($env:PROCESSOR_ARCHITECTURE -eq 'AMD64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'AMD64') { + $Arch = 'amd64' +} else { + Write-Error "Please does not support the $env:PROCESSOR_ARCHITECTURE architecture on Windows." + exit 1 +} + +# A GitHub release has no latest_version file; the redirect on /releases/latest names the tag. +if ($UrlBase -like '*github.com*') { + $Repo = ($UrlBase -replace '/releases/download$', '') + $Latest = (Invoke-WebRequest -UseBasicParsing "$Repo/releases/latest" -MaximumRedirection 0 -ErrorAction SilentlyContinue).Headers.Location + if (-not $Latest) { $Latest = (Invoke-WebRequest -UseBasicParsing "$Repo/releases/latest").BaseResponse.RequestMessage.RequestUri.AbsoluteUri } + $Version = ($Latest -split '/')[-1] -replace '^v', '' +} else { + $Version = (Invoke-WebRequest -UseBasicParsing "$UrlBase/latest_version").Content.Trim() +} +$Location = Join-Path $env:USERPROFILE '.please' +$Dir = Join-Path $Location $Version +$Zip = Join-Path ([System.IO.Path]::GetTempPath()) "please_$Version.zip" + +Write-Host "Downloading Please $Version..." -ForegroundColor Green +if (Test-Path $Dir) { Remove-Item -Recurse -Force $Dir } +New-Item -ItemType Directory -Force -Path $Dir | Out-Null +# The two layouts differ: a release keeps everything under one tag with the platform in the +# filename, the bucket keeps a directory per platform and version. +$Url = if ($UrlBase -like '*github.com*') { + "$UrlBase/v$Version/please_${Version}_windows_${Arch}.zip" +} else { + "$UrlBase/windows_${Arch}/$Version/please_$Version.zip" +} +Invoke-WebRequest -UseBasicParsing $Url -OutFile $Zip + +# The zip holds everything under a please/ directory, which is the layer the tarball strips with +# --strip-components=1. Expand-Archive has no equivalent, so unpack and move up. +$Staging = Join-Path ([System.IO.Path]::GetTempPath()) "please_$Version" +if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging } +Expand-Archive -Path $Zip -DestinationPath $Staging +Move-Item (Join-Path $Staging 'please\*') $Dir +Remove-Item -Recurse -Force $Staging, $Zip + +# Link it all back up a directory. Symlinks need Developer Mode on Windows, so hard-link where +# we can and copy where we can't; this is the same choice the self-updater and pleasew.ps1 make. +foreach ($file in Get-ChildItem -File $Dir) { + $link = Join-Path $Location $file.Name + if (Test-Path $link) { Remove-Item -Force $link } + try { + New-Item -ItemType HardLink -Path $link -Target $file.FullName -ErrorAction Stop | Out-Null + } catch { + Copy-Item -Force $file.FullName $link + } +} + +Write-Host "Please installed to $Location" -ForegroundColor Green +Write-Host "Add it to your PATH to use plz from anywhere:" +Write-Host " [Environment]::SetEnvironmentVariable('Path', `"`$env:Path;$Location`", 'User')" diff --git a/tools/please_shim/BUILD b/tools/please_shim/BUILD index e255e67dd4..d7a151d01c 100644 --- a/tools/please_shim/BUILD +++ b/tools/please_shim/BUILD @@ -17,6 +17,7 @@ go_binary( "//src/cli/logging", "//src/core", "//src/fs", + "//src/process", "//src/update", "//src/version", ], diff --git a/tools/please_shim/main.go b/tools/please_shim/main.go index 6a3fb1fc2e..7fd67628b8 100644 --- a/tools/please_shim/main.go +++ b/tools/please_shim/main.go @@ -7,7 +7,6 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "github.com/thought-machine/go-flags" @@ -15,6 +14,7 @@ import ( "github.com/thought-machine/please/src/cli/logging" "github.com/thought-machine/please/src/core" "github.com/thought-machine/please/src/fs" + "github.com/thought-machine/please/src/process" "github.com/thought-machine/please/src/update" "github.com/thought-machine/please/src/version" ) @@ -214,7 +214,7 @@ func main() { } resolvePleaseLocation(config) - state.pleaseExecutable = filepath.Join(config.Please.Location, "please") + state.pleaseExecutable = filepath.Join(config.Please.Location, "please"+fs.ExeSuffix) // Install Please if not found. if !fs.FileExists(state.pleaseExecutable) { @@ -226,7 +226,7 @@ func main() { command := cli.ActiveFullCommand(parser.Command) maybeUpdatePlease(state, command == "update") - if err := syscall.Exec(state.pleaseExecutable, os.Args, os.Environ()); err != nil { + if err := process.ExecReplace(state.pleaseExecutable, os.Args, os.Environ()); err != nil { log.Fatalf("Failed to execute Please: %s", err) } }