App-Ariza.git | t/golden/ | ci-release.yml


# Build a self-contained Example App bundle for every platform
# App::ExampleApp declares in ariza.toml, prove each one runs, and publish
# them on a tag.
#
# Generated by ariza 0.0.1 (`ariza scaffold-ci`) from these
# bundle.platforms:
#
#     macos-arm64, linux-x86_64-glibc, windows-x86_64
#
# Re-run scaffold-ci after changing that list: this file is rewritten in
# place, so a hand edit here is an edit you will make twice.
#
# Two triggers, and only one of them publishes anything:
#
#   * workflow_dispatch builds and smokes every lane and stops there,
#     with an optional `ref` so a recipe can be iterated on a branch.
#     A broken lane costs a run, not a tag.
#
#   * a pushed release tag (v* or [0-9]*.[0-9]*.[0-9]* — the
#     v-prefixed shape humans write, and the bare one mi6 creates)
#     does all of that, then publishes the
#     archives, then installs the published one on a clean machine.

name: release

on:
  workflow_dispatch:
    inputs:
      ref:
        description: >-
          Branch, tag or SHA to build. Defaults to the ref the run was
          started from.
        required: false
        type: string
  push:
    tags:
      - 'v*'
      - '[0-9]*.[0-9]*.[0-9]*'

permissions:
  contents: write

env:
  LANG: en_US.UTF-8
  LC_ALL: en_US.UTF-8

jobs:
  bundle-macos-arm64:
    name: bundle macos-arm64
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      # This Raku runs ariza. It is NOT the runtime that ends up in the
      # bundle -- ariza downloads the pinned one (2026.07-01) from
      # rakudo.org itself, so this lane's Rakudo version has no bearing
      # on what a user gets.
      - uses: Raku/setup-raku@v1
        with:
          raku-version: 'latest'

      - name: Install ariza
        run: zef install --/test 'App::Ariza:ver<0.2.2+>:auth<zef:apogee>'
        # Scaffolded by ariza 0.0.1. Before App::Ariza is
        # published, or to cut a release against an unreleased ariza,
        # install it from the repository instead:
        #   run: zef install --/test https://github.com/m-doughty/App-Ariza.git

      # ariza takes SQLCipher from the build machine's package manager --
      # there is no ariza-operated mirror -- and makes the copy it stages
      # self-contained, rewriting its OpenSSL dependency to @loader_path
      # and re-signing it. `SQLCIPHER_LIB_DIR=<dir>` overrides this if a
      # particular build is ever needed instead.
      - name: Install SQLCipher
        run: brew install sqlcipher

      - name: Build the bundle
        run: ariza bundle --app=. --platform=macos-arm64 --out-dir=dist-out

      # Unpacks the archive somewhere new, with a replaced environment,
      # and runs App::ExampleApp's own bundle.smoke commands against it.
      - name: Smoke the bundle
        run: |
          set -euo pipefail
          archive=$(ls dist-out/*.tar.gz)
          ariza smoke --archive="$archive"

      - name: Upload
        uses: actions/upload-artifact@v6
        with:
          name: macos-arm64
          path: |
            dist-out/*.tar.gz
            dist-out/*.tar.gz.sha256
          if-no-files-found: error

  bundle-linux-x86_64-glibc:
    name: bundle linux-x86_64-glibc
    runs-on: ubuntu-latest
    # Built in manylinux_2_28 (AlmaLinux 8) so the bundle's glibc floor is
    # 2.28 -- RHEL 8+, Ubuntu 18.10+, Debian 10+ -- rather than whatever
    # ubuntu-latest happens to ship this month. A bundle cannot be older
    # than the machine that built it, and this is the oldest still-
    # maintained baseline worth targeting.
    #
    # `container:` rather than `docker run` from the host: glibc 2.28 meets
    # the floor GitHub's JavaScript actions need, so checkout and
    # upload-artifact run in here unmodified. (Notcurses-Native drives
    # docker directly instead, because its musl lane cannot.)
    container: quay.io/pypa/manylinux_2_28_x86_64
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      # python3 reads the Rakudo release index below; tcl and
      # openssl3-devel build SQLCipher. The image already has ldd, readelf
      # and patchelf, which ariza needs to make the staged library
      # self-contained and to audit the result.
      - name: Install build dependencies
        run: dnf install -y python3 tcl openssl3-devel

      # Raku/setup-raku does not run in a container -- it installs into the
      # runner's tool cache, which is outside this filesystem -- so the
      # official rakudo.org archive is fetched by hand. It is resolved out
      # of the same JSON release index App::Ariza::Rakudo reads, at the
      # same pin (2026.07-01), because there is no URL pattern to
      # construct: upstream filenames carry a toolchain suffix.
      #
      # This Raku runs ariza. The runtime that ends up in the bundle is a
      # second copy ariza downloads for itself.
      - name: Install Rakudo 2026.07-01
        run: |
          set -euo pipefail
          # rakudo.org rejects urllib's default user agent, so: curl.
          curl -fsSL -A 'ariza-ci' https://rakudo.org/dl/rakudo \
            -o /tmp/rakudo-index.json
          url=$(python3 - <<'PY'
          import json
          index = json.load(open("/tmp/rakudo-index.json"))
          want = [x for x in index
                  if x.get("platform") == "linux" and x.get("arch") == "x86_64"
                  and x.get("type") == "archive" and x.get("backend") == "moar"
                  and x.get("ver") == "2026.07"
                  and int(x.get("build_rev", -1)) == 1]
          if len(want) != 1:
              raise SystemExit(
                  "expected exactly one Rakudo 2026.07-01 linux/x86_64 "
                  "archive in the release index, found %d" % len(want))
          print(want[0]["url"])
          PY
          )
          echo "rakudo: $url"
          curl -fsSL "$url" -o /tmp/rakudo.tar.gz
          mkdir -p /opt/rakudo
          tar xzf /tmp/rakudo.tar.gz -C /opt/rakudo --strip-components=1
          echo /opt/rakudo/bin >> "$GITHUB_PATH"
          echo /opt/rakudo/share/perl6/site/bin >> "$GITHUB_PATH"

      - name: Install ariza
        run: zef install --/test 'App::Ariza:ver<0.2.2+>:auth<zef:apogee>'
        # Scaffolded by ariza 0.0.1. Before App::Ariza is
        # published, or to cut a release against an unreleased ariza,
        # install it from the repository instead:
        #   run: zef install --/test https://github.com/m-doughty/App-Ariza.git

      # SQLCipher is built from source at the version ariza pins
      # (4.14.0), against this image's OpenSSL, and
      # installed where `ldconfig` will find it. ariza then copies the
      # library into the bundle along with the libcrypto it needs, and
      # points both at $ORIGIN.
      #
      # 4.14 links OpenSSL 3 (its codec calls EVP_MAC_*, missing from
      # EL8's default OpenSSL 1.1), which EL8 parallel-ships under
      # /usr/include/openssl3 and /usr/lib64/openssl3 rather than as the
      # system OpenSSL. --with-tempstore is that flag's spelling under
      # SQLCipher 4.14's autosetup-based configure -- the old --enable-*
      # form is gone. --dll-basename=libsqlcipher --soname=legacy restore
      # the libsqlcipher.so.0 layout every consumer here expects (the
      # library's internal DT_SONAME still reads libsqlite3.so.0; harmless,
      # since nothing dlopens it by soname). The ld.so.conf.d line exists
      # because EL8's ldconfig does not search /usr/local/lib by default.
      #
      # Not the distribution package: EPEL's is a 3.34.1-era build with a
      # renamed soname, years behind the pin. ariza can find it -- it looks
      # for libsqlcipher*.so* when the canonical name is absent -- but
      # shipping it to users is a different question from being able to
      # stage it.
      - name: Build and install SQLCipher 4.14.0
        run: |
          set -euo pipefail
          curl -fsSL \
            "https://github.com/sqlcipher/sqlcipher/archive/refs/tags/v4.14.0.tar.gz" \
            -o /tmp/sqlcipher.tar.gz
          mkdir -p /tmp/sqlcipher-src
          tar xzf /tmp/sqlcipher.tar.gz -C /tmp/sqlcipher-src --strip-components=1
          cd /tmp/sqlcipher-src
          ./configure --prefix=/usr/local --with-tempstore=yes \
            --dll-basename=libsqlcipher --soname=legacy \
            CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_EXTRA_INIT=sqlcipher_extra_init -DSQLITE_EXTRA_SHUTDOWN=sqlcipher_extra_shutdown -I/usr/include/openssl3" \
            LDFLAGS="-L/usr/lib64/openssl3 -lcrypto"
          make -j"$(nproc)"
          make install
          echo /usr/local/lib > /etc/ld.so.conf.d/local.conf
          ldconfig
          # ldconfig -p is soname-keyed and the soname deliberately says libsqlite3, so ask the filesystem, not the linker cache (same rule as the proof script).
          test -f /usr/local/lib/libsqlcipher.so.0
          ls -l /usr/local/lib/libsqlcipher*

      - name: Build the bundle
        run: ariza bundle --app=. --platform=linux-x86_64-glibc --out-dir=dist-out

      # Unpacks the archive somewhere new, with a replaced environment,
      # and runs App::ExampleApp's own bundle.smoke commands against it.
      - name: Smoke the bundle
        run: |
          set -euo pipefail
          archive=$(ls dist-out/*.tar.gz)
          ariza smoke --archive="$archive"

      - name: Upload
        uses: actions/upload-artifact@v6
        with:
          name: linux-x86_64-glibc
          path: |
            dist-out/*.tar.gz
            dist-out/*.tar.gz.sha256
          if-no-files-found: error

  bundle-windows-x86_64:
    name: bundle windows-x86_64
    runs-on: windows-latest
    # No `defaults: run: shell: bash` here, on purpose: zef -- both the one
    # installing ariza below and the one ariza's own site-install runs
    # inside `ariza bundle` -- resolves `tar` off whatever shell launched
    # it. Under bash that is Git Bash's MSYS tar, which treats a leading
    # `C:` in a path as a remote hostname and aborts extracting into any
    # mixed-separator temp path zef hands it. The runner's default pwsh
    # instead finds Windows' own bsdtar, which has no such reading of `C:`.
    # zef's child processes inherit whatever shell ran the step, so pwsh
    # has to be load-bearing for every step that might invoke it, not just
    # the ones below that already needed PowerShell for path handling.
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ inputs.ref || github.ref }}

      # This Raku runs ariza. It is NOT the runtime that ends up in the
      # bundle -- ariza downloads the pinned one (2026.07-01) from
      # rakudo.org itself.
      - uses: Raku/setup-raku@v1
        with:
          raku-version: 'latest'

      - name: Install ariza
        run: zef install --/test 'App::Ariza:ver<0.2.2+>:auth<zef:apogee>'
        # Scaffolded by ariza 0.0.1. Before App::Ariza is
        # published, or to cut a release against an unreleased ariza,
        # install it from the repository instead:
        #   run: zef install --/test https://github.com/m-doughty/App-Ariza.git

      # Windows has no package manager ariza drives, so it is told where
      # the library is: SQLCIPHER_LIB_DIR names the directory holding the
      # SQLCipher DLL and beats every other source. (ariza probes MSYS2's
      # prefixes and a VCPKG_ROOT tree by itself, but naming the directory
      # outright is one less thing to be wrong about.)
      #
      # MSYS2's UCRT package rather than vcpkg's, and the difference is
      # not a preference:
      #
      #   * vcpkg builds with MSVC, so its sqlcipher.dll imports
      #     vcruntime140.dll -- the Visual C++ Redistributable, which is
      #     NOT part of Windows. Every CI runner has it installed, so a
      #     bundle built that way passes every check here and fails to
      #     load on a clean machine, which is the machine a bundle is for.
      #     (ariza's PE audit now refuses that bundle outright.) MSYS2's
      #     mingw-w64-ucrt-x86_64-* packages import ucrtbase.dll instead,
      #     which Windows has shipped in System32 since Windows 10.
      #   * The MSYS2 package is prebuilt. The vcpkg port was a fifteen-
      #     minute source build, which is why this lane used to carry a
      #     restore/save cache pair around it; both are gone.
      #   * Its whole runtime closure -- OpenSSL, the mingw runtime --
      #     lands in ucrt64\bin beside the DLL, which is the search space
      #     contract App::Ariza::Native relies on to copy dependencies in.
      #
      # MSYS2 is preinstalled at C:\msys64 on windows runners, so this is
      # a package install and nothing else. `-Sy` refreshes the package
      # databases first: the image's copy is as old as the image, and
      # MSYS2's mirrors keep only current versions, so a stale database
      # sends pacman after a file that is no longer there.
      - name: Install SQLCipher
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          & C:\msys64\usr\bin\pacman.exe -Sy --noconfirm --needed mingw-w64-ucrt-x86_64-sqlcipher
          if ($LASTEXITCODE -ne 0) { throw "pacman failed (exit $LASTEXITCODE)" }

      # The name is a wildcard because MSYS2's is libsqlcipher-0.dll,
      # where vcpkg's was sqlcipher.dll: ariza accepts either and stages
      # whichever it finds under the canonical name. Asserted rather than
      # assumed -- a pacman that exits 0 having installed nothing would
      # otherwise surface as a confusing failure inside `ariza bundle`.
      - name: Locate SQLCipher
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          $lib = 'C:\msys64\ucrt64\bin'
          if (-not (Test-Path (Join-Path $lib 'libsqlcipher*.dll'))) {
              throw "pacman installed no libsqlcipher*.dll in $lib"
          }
          "SQLCIPHER_LIB_DIR=$lib" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8

      - name: Build the bundle
        run: |
          $ErrorActionPreference = 'Stop'
          ariza bundle --app=. --platform=windows-x86_64 --out-dir=dist-out
          if ($LASTEXITCODE -ne 0) { throw "ariza bundle failed (exit $LASTEXITCODE)" }

      # Unpacks the archive somewhere new, with a replaced environment,
      # and runs App::ExampleApp's own bundle.smoke commands against it.
      - name: Smoke the bundle
        run: |
          $ErrorActionPreference = 'Stop'
          $archive = (Get-ChildItem dist-out\*.tar.gz | Select-Object -First 1).FullName
          ariza smoke --archive="$archive"
          if ($LASTEXITCODE -ne 0) { throw "ariza smoke failed (exit $LASTEXITCODE)" }

      - name: Upload
        uses: actions/upload-artifact@v6
        with:
          name: windows-x86_64
          path: |
            dist-out/*.tar.gz
            dist-out/*.tar.gz.sha256
          if-no-files-found: error

  publish:
    name: publish
    needs:
      - bundle-macos-arm64
      - bundle-linux-x86_64-glibc
      - bundle-windows-x86_64
    # Tag-gated: everything above this line has already proved itself by
    # the time anything is published, and a dispatch run is free to fail.
    if: startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    steps:
      - name: Download every lane's artefact
        uses: actions/download-artifact@v7
        with:
          path: downloaded/

      - name: Flatten, and build one checksums.txt over the lot
        shell: bash
        run: |
          set -euxo pipefail
          mkdir -p release
          find downloaded -type f \( -name '*.tar.gz' \
                                 -o -name '*.zip' \
                                 -o -name '*.sha256' \) \
            -exec cp {} release/ \;

          # ariza writes a `<archive>.sha256` sidecar beside each bundle,
          # and install.sh checks it. checksums.txt is those same digests
          # in one file, for a human with `sha256sum -c`. Recomputed here
          # rather than concatenated, and then checked against the
          # sidecars: a release whose digest refuses its own archive is
          # worse than one with no digest at all.
          cd release
          : > ../checksums.txt
          for f in *; do
            case $f in *.sha256) continue ;; esac
            sha256sum "$f" >> ../checksums.txt
          done
          sort -o ../checksums.txt ../checksums.txt
          sha256sum -c -- *.sha256
          cd ..

          echo '--- checksums.txt ---'
          cat checksums.txt

      - name: Create the release
        uses: softprops/action-gh-release@v3
        with:
          tag_name: ${{ github.ref_name }}
          name: Example App ${{ github.ref_name }}
          files: |
            release/*
            checksums.txt
          body: |
            Each archive below is a **self-contained bundle**:
            Example App, a Rakudo runtime (2026.07-01), every Raku
            dependency with its bytecode already compiled, and every native
            library the application loads. There is nothing to install
            first -- no Raku, no compiler, no package manager. Unpack it
            anywhere, run `bin/exampleapp`, and delete the directory when
            you are done: that is the uninstall.

            ### Platforms

            - `macos-arm64`
              Apple silicon, macOS 11 (Big Sur) or newer.
            - `linux-x86_64-glibc`
              x86_64, glibc 2.28 or newer: RHEL 8+, Ubuntu 18.10+,
              Debian 10+. Not Alpine or any other musl distribution.
            - `windows-x86_64`
              x86_64, Windows 10 or newer.

            A bundle runs on the platform it names and no other. It carries
            compiled code, so there is no nearest match to fall back on.

            ### Installing

            ```
            curl -fsSL https://raw.githubusercontent.com/example-org/App-ExampleApp/HEAD/install.sh | sh
            ```

            That script picks the right archive for the machine it runs on,
            verifies its SHA-256, unpacks it under
            `$XDG_DATA_HOME/exampleapp/versions/` and links
            `~/.local/bin/exampleapp` at it. Windows has `install.ps1`
            beside it. Neither needs root, and `uninstall.sh` /
            `uninstall.ps1` undo exactly what they did.

            ### Verifying a download

            ```
            sha256sum -c checksums.txt
            ```

            Or one archive at a time, against the `.sha256` published beside
            it. On macOS, `shasum -a 256 -c` reads either.
          draft: false
          prerelease: false

  smoke-installer-macos-arm64:
    name: smoke the published installer (macos-arm64)
    needs: publish
    if: startsWith(github.ref, 'refs/tags/')
    # A plain runner, deliberately: no container, no toolchain, nothing
    # this repository put there. Whatever the bundle needs, the bundle has
    # to carry -- and this, together with its Linux and Windows siblings
    # below, is the only coverage that tests that claim against the
    # artefact a user will actually download.
    #
    # It is also the only machine in this workflow with a BSD userland, so
    # it is the only place install.sh's BSD branches ever run: `shasum -a
    # 256` where Linux has `sha256sum`, and bsdtar rather than GNU tar.
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v6

      - name: Download the published macos-arm64 bundle
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          mkdir -p published
          # The archive and its digest sidecar, so install.sh has
          # something to verify against.
          gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
            --pattern 'exampleapp-*-macos-arm64.tar.gz*' --dir published
          ls -l published

      - name: Install it with this repository's own install.sh
        run: |
          set -euo pipefail
          # The committed script, not a raw.githubusercontent URL: this has
          # to work for the first release, before that URL serves anything,
          # and it is the script this tag actually ships.
          archive=$(ls published/*.tar.gz)
          sh ./install.sh --url "$PWD/$archive"

      - name: Run the installed launcher with a replaced environment
        run: |
          set -euo pipefail
          # `env -i`: the launcher gets HOME, PATH and TERM and nothing
          # else, so it cannot pass by borrowing something the runner image
          # happens to have lying around.
          env -i HOME="$HOME" PATH=/usr/bin:/bin TERM=xterm \
            "$HOME/.local/bin/exampleapp" --version

  smoke-installer-linux-x86_64-glibc:
    name: smoke the published installer (linux-x86_64-glibc)
    needs: publish
    if: startsWith(github.ref, 'refs/tags/')
    # A plain runner, deliberately: no container, no toolchain, nothing
    # this repository put there. Whatever the bundle needs, the bundle has
    # to carry -- and this, together with its macOS and Windows siblings
    # below, is the only coverage that tests that claim against the
    # artefact a user will actually download.
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Download the published linux-x86_64-glibc bundle
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          mkdir -p published
          # The archive and its digest sidecar, so install.sh has
          # something to verify against.
          gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
            --pattern 'exampleapp-*-linux-x86_64-glibc.tar.gz*' --dir published
          ls -l published

      - name: Install it with this repository's own install.sh
        run: |
          set -euo pipefail
          # The committed script, not a raw.githubusercontent URL: this has
          # to work for the first release, before that URL serves anything,
          # and it is the script this tag actually ships.
          archive=$(ls published/*.tar.gz)
          sh ./install.sh --url "$PWD/$archive"

      - name: Run the installed launcher with a replaced environment
        run: |
          set -euo pipefail
          # `env -i`: the launcher gets HOME, PATH and TERM and nothing
          # else, so it cannot pass by borrowing something the runner image
          # happens to have lying around.
          env -i HOME="$HOME" PATH=/usr/bin:/bin TERM=xterm \
            "$HOME/.local/bin/exampleapp" --version

  smoke-installer-windows-x86_64:
    name: smoke the published installer (windows-x86_64)
    needs: publish
    if: startsWith(github.ref, 'refs/tags/')
    # A plain runner, deliberately: no SQLCipher from any package manager,
    # no setup-raku, nothing this repository put there. Whatever the
    # bundle needs, the bundle has to carry -- and this, together with its
    # macOS and Linux siblings above, is the only coverage that tests that
    # claim against the artefact a user will actually download.
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v6

      - name: Download the published windows-x86_64 bundle
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          $ErrorActionPreference = 'Stop'
          New-Item -ItemType Directory -Path published -Force | Out-Null
          # ariza packages every platform's bundle as .tar.gz, Windows
          # included -- there is no `.zip` lane to fall back to -- so this
          # is the archive and its digest sidecar, the same pattern the
          # macOS and Linux jobs use above.
          gh release download $env:GITHUB_REF_NAME --repo $env:GITHUB_REPOSITORY --pattern 'exampleapp-*-windows-x86_64.*' --dir published
          if ($LASTEXITCODE -ne 0) { throw "gh release download failed (exit $LASTEXITCODE)" }
          Get-ChildItem published

      - name: Install it with this repository's own install.ps1
        run: |
          $ErrorActionPreference = 'Stop'
          # The committed script, not a raw.githubusercontent URL: this has
          # to work for the first release, before that URL serves anything,
          # and it is the script this tag actually ships.
          $archives = @(Get-ChildItem published\*.tar.gz)
          if ($archives.Count -ne 1) {
              throw "expected exactly one .tar.gz in published/, found $($archives.Count)"
          }
          pwsh -File .\install.ps1 -Url $archives[0].FullName
          if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed (exit $LASTEXITCODE)" }

      - name: Run the installed launcher with a minimal environment
        run: |
          $ErrorActionPreference = 'Stop'
          $bin = Join-Path $env:LOCALAPPDATA 'Example App\current\bin'
          $exe = Join-Path $bin 'exampleapp.cmd'
          if (-not (Test-Path -LiteralPath $exe)) {
              throw "install.ps1 did not leave $exe behind"
          }

          # PowerShell has no `env -i`: a fresh process is built by hand
          # instead, with its environment cleared and PATH trimmed to the
          # system directory plus the install's own bin, so the bundle
          # cannot pass by borrowing a toolchain this runner happens to
          # have lying around. `pwsh` is resolved before the environment
          # is touched, because the child cannot find it once PATH is gone.
          $pwsh = (Get-Command pwsh).Source
          $psi = [System.Diagnostics.ProcessStartInfo]::new()
          $psi.FileName = $pwsh
          $psi.Arguments = "-NoProfile -NonInteractive -Command & '$exe' --version"
          $psi.UseShellExecute = $false
          $psi.RedirectStandardOutput = $true
          $psi.RedirectStandardError = $true
          $psi.EnvironmentVariables.Clear()
          $psi.EnvironmentVariables['PATH'] = "$env:SystemRoot\System32;$bin"
          $psi.EnvironmentVariables['USERPROFILE'] = $env:USERPROFILE
          $psi.EnvironmentVariables['LOCALAPPDATA'] = $env:LOCALAPPDATA
          $psi.EnvironmentVariables['TEMP'] = $env:TEMP
          # Windows has mandatory ambient environment that `env -i` on
          # POSIX has no analogue for, and stripping it does not make the
          # test cleaner — it makes the OS itself malfunction. Launching
          # any .cmd resolves the interpreter through ComSpec, so without
          # it the batch launcher dies with ERROR_MOD_NOT_FOUND ("The
          # specified module could not be found") before the launcher
          # runs a line — which is exactly how the first published
          # Windows installer smoke failed against a perfectly good
          # bundle. SystemRoot and windir are how cmd and half of Win32
          # find the OS; PATHEXT is how `moneymoor` resolves to .exe/.cmd
          # at all; SystemDrive is cheap insurance of the same kind.
          # These five belong to Windows, not to the runner's toolchain,
          # so keeping them does not weaken the isolation the cleared
          # environment exists to provide.
          $psi.EnvironmentVariables['SystemRoot'] = $env:SystemRoot
          $psi.EnvironmentVariables['windir'] = $env:windir
          $psi.EnvironmentVariables['ComSpec'] = $env:ComSpec
          $psi.EnvironmentVariables['PATHEXT'] = $env:PATHEXT
          $psi.EnvironmentVariables['SystemDrive'] = $env:SystemDrive

          $proc = [System.Diagnostics.Process]::Start($psi)
          $out = $proc.StandardOutput.ReadToEnd()
          $err = $proc.StandardError.ReadToEnd()
          $proc.WaitForExit()
          Write-Host $out
          if ($err) { Write-Host $err }
          if ($proc.ExitCode -ne 0) {
              throw "$exe --version exited $($proc.ExitCode)"
          }

          # The same launch through the compiled runner, when the bundle
          # ships one. The .cmd above proves the script path; this proves
          # the entry point PATHEXT actually hands a user who types the
          # bare command — and unlike the .cmd it involves no cmd.exe and
          # no ComSpec, so the two smokes fail independently.
          $native = Join-Path $bin 'exampleapp.exe'
          if (Test-Path -LiteralPath $native) {
              $psi.Arguments = "-NoProfile -NonInteractive -Command & '$native' --version"
              $proc = [System.Diagnostics.Process]::Start($psi)
              $out = $proc.StandardOutput.ReadToEnd()
              $err = $proc.StandardError.ReadToEnd()
              $proc.WaitForExit()
              Write-Host $out
              if ($err) { Write-Host $err }
              if ($proc.ExitCode -ne 0) {
                  throw "$native --version exited $($proc.ExitCode)"
              }
          }