name: clean-install on: workflow_dispatch: inputs: slug: description: Tool slug (folder name) required: true push: branches: [main] paths: ["*/**", "!.github/**"] permissions: contents: read jobs: detect: runs-on: ubuntu-latest outputs: slugs: ${{ steps.d.outputs.slugs }} steps: - uses: actions/checkout@v4 with: persist-credentials: false fetch-depth: 0 - id: d env: INPUT: ${{ inputs.slug }} BEFORE: ${{ github.event.before }} run: | if [ -n "$INPUT" ]; then dirs="$INPUT" else dirs=$(git diff --name-only "$BEFORE" HEAD 2>/dev/null || git diff --name-only HEAD~1 HEAD); fi slugs=$(for d in $(echo "$dirs" | cut -d/ -f1 | sort -u); do if [ -f "$d/tool.json" ]; then echo "$d"; fi; done | jq -R . | jq -cs .) echo "slugs=$slugs" | tee -a "$GITHUB_OUTPUT" [ -n "$INPUT" ] && [ "$slugs" = "[]" ] && { echo "::error::no $INPUT/tool.json"; exit 1; } || true - name: wait for raw.githubusercontent.com to serve this commit env: SLUGS: ${{ steps.d.outputs.slugs }} run: | for s in $(echo "$SLUGS" | jq -r '.[]'); do for f in install.sh install.ps1; do for i in $(seq 40); do curl -fsSL "https://raw.githubusercontent.com/chayprabs/localtools/main/$s/$f?nocache=$RANDOM" | cmp -s - "$s/$f" && break [ "$i" = 40 ] && { echo "::error::raw $s/$f still stale"; exit 1; } sleep 10 done done done install: needs: detect if: needs.detect.outputs.slugs != '[]' strategy: fail-fast: false matrix: slug: ${{ fromJSON(needs.detect.outputs.slugs) }} os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} env: LOCALTOOLS_NO_OPEN: "1" SLUG: ${{ matrix.slug }} defaults: run: shell: bash steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: read tool.json run: | jq() { command jq "$@" | tr -d '\r'; } # jq on Windows prints CRLF kind=$(jq -r .kind "$SLUG/tool.json") full=true; [ "$kind" = docker ] && [ "$RUNNER_OS" != Linux ] && full=false { echo "KIND=$kind"; echo "FULL=$full" echo "PORT=$(jq -r .port "$SLUG/tool.json")"; echo "NAME=$(jq -r .name "$SLUG/tool.json")" echo "VERSION=$(jq -r .version "$SLUG/tool.json")"; } >> "$GITHUB_ENV" - name: install (Windows PowerShell 5.1) if: runner.os == 'Windows' && env.FULL == 'true' shell: powershell run: irm https://raw.githubusercontent.com/chayprabs/localtools/main/${{ matrix.slug }}/install.ps1 | iex - name: anonymous Docker image pull if: runner.os == 'Linux' && env.KIND == 'docker' run: | image=$(jq -r .registries.ghcr "$SLUG/tool.json") # Fresh credential directory proves the public path rather than runner login state. DOCKER_CONFIG=$(mktemp -d) docker pull "$image:$VERSION" - name: install (bash) if: runner.os != 'Windows' && env.FULL == 'true' run: curl -fsSL https://raw.githubusercontent.com/chayprabs/localtools/main/${{ matrix.slug }}/install.sh | bash - name: docker preflight fails clearly (Windows) if: runner.os == 'Windows' && env.FULL == 'false' shell: powershell run: | $msg = '' try { irm https://raw.githubusercontent.com/chayprabs/localtools/main/${{ matrix.slug }}/install.ps1 | iex } catch { $msg = $_.Exception.Message } "installer said: $msg" if ($msg -notmatch 'Docker') { throw 'expected a Docker preflight failure' } - name: docker preflight fails clearly (macOS) if: runner.os == 'macOS' && env.FULL == 'false' run: | if out=$(curl -fsSL https://raw.githubusercontent.com/chayprabs/localtools/main/${{ matrix.slug }}/install.sh | bash 2>&1); then echo "expected failure"; exit 1; fi echo "installer said: $out"; grep -q Docker <<<"$out" - uses: actions/setup-python@v5 if: (matrix.slug == 'pandoc-gui' || matrix.slug == 'og-image-generator' || matrix.slug == 'latex-to-svg') && env.FULL == 'true' with: { python-version: "3.13" } - name: shim, launcher, health, smoke, version, update, uninstall if: env.FULL == 'true' run: | jq() { command jq "$@" | tr -d '\r'; } # jq on Windows prints CRLF home=~; T=localtools-$SLUG; R=~/.localtools case "$RUNNER_OS" in Windows) T=$T.cmd; L="$(cygpath "$APPDATA")/Microsoft/Windows/Start Menu/Programs/localtools/$NAME.lnk" onpath() { reg query 'HKCU\Environment' //v Path | grep -qi '\.localtools\\bin'; } ;; macOS) L="$home/Applications/localtools $NAME.app/Contents/MacOS/launcher"; onpath() { grep -qs '# localtools$' ~/.profile; } ;; Linux) L="$home/.local/share/applications/localtools-$SLUG.desktop"; onpath() { grep -qs '# localtools$' ~/.profile; } ;; esac export PATH="$R/bin:$PATH" test -f "$R/bin/$T"; test -e "$L"; onpath; echo "shim, launcher, PATH: ok" health() { for _ in $(seq 90); do curl -fs "http://127.0.0.1:$PORT/api/health" | grep -Eq "\"tool\": *\"$SLUG\"" && { echo "health ok"; return 0; } sleep 2 done cat server.log; return 1 } start() { nohup "$T" --no-open > server.log 2>&1 & health; } until_changed() { for _ in $(seq 120); do [ "$(cat "$1" 2>/dev/null)" != "$2" ] && return 0; sleep 2; done; return 1; } start S="$SLUG/smoke/smoke.json"; args=() while IFS= read -r k; do [ -n "$k" ] && args+=(--form-string "$k=$(jq -r --arg k "$k" '.fields[$k]' "$S")"); done < <(jq -r '.fields|keys[]' "$S") while IFS= read -r k; do [ -n "$k" ] && args+=(-F "$k=@$SLUG/smoke/$(jq -r --arg k "$k" '.files[$k]' "$S")"); done < <(jq -r '.files|keys[]' "$S") curl -fsS "${args[@]}" "http://127.0.0.1:$PORT$(jq -r .path "$S")" -o smoke.out grep -aqF -- "$(jq -r .expect "$S")" smoke.out; echo "smoke ok ($(wc -c < smoke.out) bytes)" if [ "$SLUG" = pandoc-gui ] || [ "$SLUG" = og-image-generator ] || [ "$SLUG" = latex-to-svg ]; then python "$SLUG/verify.py"; fi "$T" --version | tee version.out; grep -qF "$VERSION" version.out before=$(cat "$R/$SLUG/installed.txt") "$T" update | tee update.out until_changed "$R/$SLUG/installed.txt" "$before"; echo "update ok" if grep -q 'logged to' update.out; then # detached Windows update: its log must show the finished install for _ in $(seq 30); do grep -qs 'installed' "$R/$SLUG-install.log" && break; sleep 2; done cat "$R/$SLUG-install.log"; grep -q 'installed' "$R/$SLUG-install.log"; echo "update log ok" fi sleep 3; start "$T" uninstall || true for _ in $(seq 60); do [ -e "$R/$SLUG" ] || break; sleep 2; done for p in "$R/$SLUG" "$R/bin/$T" "$L"; do [ ! -e "$p" ] || { echo "left behind: $p"; exit 1; }; done if onpath; then echo "PATH entry left behind"; exit 1; fi; echo "uninstall ok" - name: Docker port forwarding, custom prefix, update and uninstall if: runner.os == 'Linux' && env.KIND == 'docker' run: | export LOCALTOOLS_HOME="$RUNNER_TEMP/office custom prefix" curl -fsSL "https://raw.githubusercontent.com/chayprabs/localtools/main/$SLUG/install.sh" | bash prefix="$LOCALTOOLS_HOME"; T="$prefix/bin/localtools-$SLUG" "$T" --version | grep -F "$VERSION" check_port() { port="$1" for _ in $(seq 90); do curl -fsS "http://127.0.0.1:$port/api/health" | grep -q 'office-converter' && return 0; sleep 1; done cat "$RUNNER_TEMP/office-custom.log"; return 1 } LOCALTOOLS_PORT=48135 "$T" --no-open > "$RUNNER_TEMP/office-custom.log" 2>&1 & check_port 48135 "$T" --no-open --port 48135 | grep -q 'already running' docker ps -aq --filter "label=localtools.install=$prefix/$SLUG" | xargs -r docker rm -f "$T" serve --port 48136 --no-open > "$RUNNER_TEMP/office-custom.log" 2>&1 & check_port 48136 docker ps -aq --filter "label=localtools.install=$prefix/$SLUG" | xargs -r docker rm -f mkdir -p "$RUNNER_TEMP/copied-office-wrapper" cp "$prefix/$SLUG/localtools-$SLUG" "$RUNNER_TEMP/copied-office-wrapper/entry" "$RUNNER_TEMP/copied-office-wrapper/entry" uninstall | grep -q 'not installed by the localtools script' before=$(cat "$prefix/$SLUG/installed.txt") unset LOCALTOOLS_HOME "$T" update test "$(cat "$prefix/$SLUG/installed.txt")" != "$before" "$T" uninstall test ! -e "$prefix/$SLUG"; test ! -e "$T" registries: needs: detect if: needs.detect.outputs.slugs != '[]' strategy: fail-fast: false matrix: slug: ${{ fromJSON(needs.detect.outputs.slugs) }} os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} env: SLUG: ${{ matrix.slug }} LOCALTOOLS_NO_OPEN: "1" steps: - uses: actions/checkout@v4 with: persist-credentials: false - shell: bash run: | jq() { command jq "$@" | tr -d '\r'; } # not BREW/SCOOP: the Scoop installer reads $env:SCOOP as its install directory { echo "PIPX_HOME=$RUNNER_TEMP/pipx-home"; echo "PIPX_BIN_DIR=$RUNNER_TEMP/pipx-bin"; } >> "$GITHUB_ENV" { echo "HAS_BREW=$(jq -r .registries.brew "$SLUG/tool.json")"; echo "HAS_SCOOP=$(jq -r .registries.scoop "$SLUG/tool.json")" echo "PYPI_PACKAGE=$(jq -r .registries.pypi "$SLUG/tool.json")" echo "VERSION=$(jq -r .version "$SLUG/tool.json")"; } >> "$GITHUB_ENV" - uses: actions/setup-python@v5 if: env.PYPI_PACKAGE != 'null' with: { python-version: "3.13" } - name: anonymous exact-version PyPI pipx install and real smoke if: env.PYPI_PACKAGE != 'null' shell: bash run: | unset GH_TOKEN GITHUB_TOKEN PIP_INDEX_URL PIP_EXTRA_INDEX_URL PIP_FIND_LINKS python -m pip --isolated install --disable-pip-version-check --index-url https://pypi.org/simple pipx python -m pipx install --python "$(python -c 'import sys; print(sys.executable)')" "$PYPI_PACKAGE==$VERSION" --pip-args="--isolated --no-cache-dir --index-url https://pypi.org/simple" entry="$PIPX_BIN_DIR/localtools-$SLUG" [ "$RUNNER_OS" != Windows ] || entry="$entry.exe" python .github/pypi-smoke.py "$SLUG" "$VERSION" "$entry" || { cat pypi-server.log; exit 1; } python -m pipx uninstall "$PYPI_PACKAGE" - name: brew install if: runner.os != 'Windows' && env.HAS_BREW == 'true' shell: bash run: | [ "$RUNNER_OS" = Linux ] && eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" brew install "chayprabs/localtools/localtools-$SLUG" "localtools-$SLUG" --version | grep -F "$VERSION" for a in update uninstall; do out=$("localtools-$SLUG" $a); echo "$out" grep -q "was installed with brew" <<<"$out" || { echo "::error::$a has no package-manager guard"; exit 1; } if grep -q "installed with" <<<"$out"; then grep -q "brew $([ $a = update ] && echo upgrade || echo uninstall) localtools-$SLUG" <<<"$out"; fi done "localtools-$SLUG" --version | grep -F "$VERSION" # still installed: the guard must not have touched it - name: scoop install if: runner.os == 'Windows' && env.HAS_SCOOP == 'true' shell: powershell run: | iex "& {$(irm get.scoop.sh)} -RunAsAdmin" scoop bucket add localtools https://github.com/chayprabs/scoop-localtools scoop install "localtools-$env:SLUG" $v = & "localtools-$env:SLUG" --version "version: $v" if ("$v" -notmatch [regex]::Escape($env:VERSION)) { throw "unexpected version" } foreach ($a in 'update', 'uninstall') { $out = (& "localtools-$env:SLUG" $a | Out-String); $out if ($out -match 'installed with') { $want = if ($a -eq 'update') { "scoop update localtools-$env:SLUG" } else { "scoop uninstall localtools-$env:SLUG" } if (-not $out.Contains($want)) { throw "guard printed the wrong command" } } else { throw "$a has no package-manager guard" } } if (-not ((& "localtools-$env:SLUG" --version) -match [regex]::Escape($env:VERSION))) { throw "guard removed the install" }