eish 0.1.0

Generate self-contained installation scripts (bash, fish, PowerShell) for GitHub release binaries
name: Release

permissions:
  contents: write

on: [push]

defaults:
  run:
    shell: bash --noprofile --norc -CeEuo pipefail {0}

env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-apple-darwin
            os: macos-latest
          - target: aarch64-apple-darwin
            os: macos-latest
          - target: x86_64-pc-windows-msvc
            os: windows-latest
            RUSTFLAGS: -C target-feature=+crt-static
          - target: x86_64-pc-windows-gnu
            os: windows-latest
          # - target: arm64ec-pc-windows-msvc
          #   os: windows-latest
          - target: aarch64-unknown-linux-musl
            os: ubuntu-latest
          - target: aarch64-unknown-linux-gnu
            os: ubuntu-latest
          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: aarch64-linux-android
            os: ubuntu-latest
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: run cross-build
        uses: ahaoboy/cross-build@v1
        with:
          bin: eish
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          target: ${{ matrix.target }}
          RUSTFLAGS: ${{ matrix.RUSTFLAGS }}
          tag: nightly
          allowUpdates: true
          features: cli

  # Generates a real installer for a few public repositories, runs each one, and
  # checks that an executable lands on disk. Between them these releases cover
  # the naming schemes that matter — full Rust triples, bare binaries with no
  # extension, and OS/arch-only names such as `ant-linux-x64` and
  # `qjs-linux-x86` — so a regression in platform detection or asset selection
  # fails the release instead of a user's install.
  #
  # A release that ships more than one program has to be named as `repo:program`,
  # because eish refuses to guess which one an installer is for. `quickjs-ng`
  # publishes both `qjs` and `qjsc`, so it carries its name.
  #
  # One runner per OS: the interesting part is running the same installer on
  # each platform, which the build matrix cannot do (those runners are all
  # x86_64 Linux, so every entry would exercise the same detection path).
  verify-installers:
    name: install (${{ matrix.name }})
    strategy:
      fail-fast: false
      matrix:
        include:
          # Which shell each runner is responsible for. fish is native to Unix
          # and macOS but is not part of the Windows images; PowerShell is the
          # reverse. Splitting them this way means every shell is exercised
          # exactly where it is actually used, and none of the runners carry an
          # interpreter they will never run.
          - name: macos-arm64
            os: macos-latest
            shells: bash fish
          - name: windows
            os: windows-latest
            shells: bash powershell
          - name: ubuntu
            os: ubuntu-latest
            shells: bash fish
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: Swatinem/rust-cache@v2

      - name: Install eish
        run: cargo install --path . --features cli

      - name: Install fish
        if: contains(matrix.shells, 'fish')
        run: |
          case "$(uname -s)" in
            Linux)
              sudo apt-get update -qq
              sudo apt-get install -y -qq fish
              ;;
            Darwin)
              brew install fish
              ;;
            *)
              echo "::error::fish is only supported on Unix and macOS runners"
              exit 1
              ;;
          esac
          fish --version

      - name: Generate and run installers
        env:
          SHELLS: ${{ matrix.shells }}
        run: |
          repos=(
            easy-install/easy-install
            boa-dev/boa
            theMackabu/ant
            ahaoboy/es5
            quickjs-ng/quickjs:qjs
          )

          # PowerShell is only exercised on Windows, where both interpreters
          # are present: `pwsh` (PowerShell 7+) and `powershell` (5.1). The
          # template has separate paths for each — TLS 1.2, `UseBasicParsing`
          # and the `$PSEdition` checks only apply to 5.1 — so run both rather
          # than letting the newer one stand in for the older.
          ps_bins=()
          case " $SHELLS " in
            *" powershell "*)
              for candidate in pwsh powershell; do
                if command -v "$candidate" >/dev/null 2>&1; then
                  ps_bins+=("$candidate")
                fi
              done
              if [ ${#ps_bins[@]} -eq 0 ]; then
                echo "::error::no PowerShell interpreter found (tried pwsh, powershell)"
                exit 1
              fi
              echo "PowerShell interpreters: ${ps_bins[*]}"
              ;;
          esac

          mkdir -p target/verify
          failed=0
          checks=0

          # Run one installer and require it to leave an executable behind.
          # Exiting 0 without installing anything is the failure this job
          # exists to catch, so the directory is checked rather than just the
          # exit status.
          verify() {
            local label="$1" dir="$2"
            shift 2

            checks=$((checks + 1))
            rm -rf "$dir"
            mkdir -p "$dir"

            echo "::group::$label"

            if ! "$@"; then
              echo "::error::$label: the installer failed"
              failed=1
            else
              # `-type f -print -quit` rather than `| head`, which would trip
              # the workflow's `pipefail` through SIGPIPE.
              local installed
              installed="$(find "$dir" -maxdepth 1 -type f -print -quit || true)"
              if [ -z "$installed" ] || [ ! -x "$installed" ]; then
                echo "::error::$label: no executable was installed into $dir"
                find "$dir" -maxdepth 1 -print || true
                failed=1
              else
                echo "installed $installed"
              fi
            fi

            echo "::endgroup::"
          }

          for entry in "${repos[@]}"; do
            # `owner/repo` or `owner/repo:program`; the suffix names the program
            # to install when the release publishes more than one.
            repo="${entry%%:*}"
            program="${entry#"$repo"}"
            program="${program#:}"

            name="${repo##*/}"
            base="target/verify/$name"

            echo "##### $entry on $(uname -s)/$(uname -m), shells: $SHELLS"

            for shell in $SHELLS; do
              # PowerShell refuses to run a script that does not end in `.ps1`,
              # so the file name cannot just be `<repo>.<shell>`.
              case "$shell" in
                bash) ext=sh ;;
                fish) ext=fish ;;
                powershell) ext=ps1 ;;
              esac
              script="$base.$ext"

              # `--name` is only passed when the entry actually names a program.
              # eish normalises an empty name away, so passing it always would
              # work, but it would then show up verbatim in the generated
              # header as `--name ''`.
              if [ -n "$program" ]; then
                ok=0
                eish "$repo" --name "$program" --shell "$shell" -o "$script" || ok=1
              else
                ok=0
                eish "$repo" --shell "$shell" -o "$script" || ok=1
              fi
              if [ "$ok" -ne 0 ]; then
                echo "::error::$entry: could not generate the $shell installer"
                failed=1
                continue
              fi

              case "$shell" in
                bash)
                  if ! bash -n "$script"; then
                    echo "::error::$repo: the generated bash is not valid"
                    failed=1
                    continue
                  fi
                  dir="$PWD/$base-bash"
                  verify "$repo/bash" "$dir" \
                    env EI_DIR="$dir" EI_MIN_DISK_SPACE=0 bash "$script"
                  ;;

                fish)
                  if ! fish -n "$script"; then
                    echo "::error::$repo: the generated fish is not valid"
                    failed=1
                    continue
                  fi
                  dir="$PWD/$base-fish"
                  verify "$repo/fish" "$dir" \
                    env EI_DIR="$dir" EI_MIN_DISK_SPACE=0 fish "$script"
                  ;;

                powershell)
                  for ps in "${ps_bins[@]}"; do
                    dir="$PWD/$base-$ps"
                    if [ "$ps" = powershell ]; then
                      args=(-NoProfile -ExecutionPolicy Bypass -File)
                    else
                      args=(-NoProfile -File)
                    fi
                    verify "$repo/$ps" "$dir" \
                      env EI_DIR="$dir" EI_MIN_DISK_SPACE=0 \
                      "$ps" "${args[@]}" "$script"
                  done
                  ;;
              esac
            done
          done

          echo "ran $checks installer checks"
          exit "$failed"