diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml new file mode 100644 index 0000000..9d9f882 --- /dev/null +++ b/.github/workflows/install-test.yml @@ -0,0 +1,69 @@ +name: Install Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + installer-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install PyYAML==6.0.3 + - run: python -m unittest discover -s tests -v + + install: + name: Install (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + binary_arch: x86-64 + - runner: ubuntu-24.04-arm + binary_arch: aarch64 + - runner: macos-latest + binary_arch: arm64 + - runner: macos-26-intel + binary_arch: x86_64 + - runner: windows-latest + binary_arch: x86-64 + - runner: windows-11-arm + binary_arch: Aarch64|ARM64 + steps: + - uses: actions/checkout@v7 + - name: Install and run info + uses: ./ + with: + command: info + - name: Verify installed architecture and PATH + shell: bash + env: + EXPECTED_ARCH: ${{ matrix.binary_arch }} + run: | + binary=$(command -v stackql-deploy) + if [ "$RUNNER_OS" = Windows ]; then binary="${binary%.exe}.exe"; fi + file "$binary" + file -b "$binary" | grep -E "$EXPECTED_ARCH" + stackql-deploy --version + git hash-object "$binary" > "$RUNNER_TEMP/stackql-deploy.hash" + - name: Reuse installed binary + uses: ./ + with: + command: info + - name: Verify binary is unchanged + shell: bash + run: | + binary=$(command -v stackql-deploy) + if [ "$RUNNER_OS" = Windows ]; then binary="${binary%.exe}.exe"; fi + git hash-object "$binary" | cmp - "$RUNNER_TEMP/stackql-deploy.hash" diff --git a/.github/workflows/stackql-deploy-actions-test.yml b/.github/workflows/stackql-deploy-actions-test.yml index e9b603c..a094af3 100644 --- a/.github/workflows/stackql-deploy-actions-test.yml +++ b/.github/workflows/stackql-deploy-actions-test.yml @@ -32,6 +32,8 @@ jobs: stack_env: ci-macos-intel - runner: windows-latest stack_env: ci-windows-amd64 + - runner: windows-11-arm + stack_env: ci-windows-arm64 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/README.md b/README.md index c73e04e..f58c080 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,25 @@ Github Action to execute `stackql-deploy` to deploy or test a stack. [`stackql- # Usage +## Supported platforms + +The action downloads the latest prebuilt release for Linux, macOS, and Windows +on x86_64 and ARM64. macOS uses the universal binary; no Rust toolchain is needed. +Windows detects the native CPU even when Git Bash runs under x64 emulation. + +| Platform | Architecture | CI runner | +|----------|--------------|-----------| +| Linux | x86_64 | `ubuntu-latest` | +| Linux | ARM64 | `ubuntu-24.04-arm` | +| macOS | Apple Silicon | `macos-latest` | +| macOS | Intel | `macos-26-intel` | +| Windows | x86_64 | `windows-latest` | +| Windows | ARM64 | `windows-11-arm` | + +CI checks installation, binary architecture, and reuse on each runner without +cloud credentials. The AWS SSM workflow also tests deployment, updates, and +teardown on each runner. + ## Provider Authentication Authentication to StackQL providers is done via environment variables source from GitHub Actions Secrets. To learn more about authentication, see the setup instructions for your provider or providers at the [StackQL Provider Registry Docs](https://stackql.io/registry). diff --git a/action.yml b/action.yml index 5ec9888..53cbc9f 100644 --- a/action.yml +++ b/action.yml @@ -51,46 +51,56 @@ runs: else OS="$(uname -s)" ARCH="$(uname -m)" + case "$OS" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) + # The registry reports the native CPU even in an emulated x64 shell. + WINDOWS_ARCH=$(powershell.exe -NoProfile -NonInteractive -Command "(Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name PROCESSOR_ARCHITECTURE -ErrorAction Stop).PROCESSOR_ARCHITECTURE" 2>/dev/null || true) + WINDOWS_ARCH="${WINDOWS_ARCH//$'\r'/}" + ARCH="${WINDOWS_ARCH:-${PROCESSOR_ARCHITEW6432:-${PROCESSOR_ARCHITECTURE:-$ARCH}}}" + OS="Windows" + ;; + esac + case "$ARCH" in + x86_64|amd64|AMD64|x64|X64) ARCH="x86_64" ;; + aarch64|arm64|ARM64) ARCH="arm64" ;; + esac echo "Detected platform: ${OS}-${ARCH}" - if [ "$OS" = "Darwin" ]; then - # macOS: build from crates.io. Prebuilt macOS assets have had naming drift - # (universal vs arch-specific), so cargo install is the reliable path here. - echo "Installing stackql-deploy via cargo..." - cargo install stackql-deploy --locked - echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" - export PATH="${HOME}/.cargo/bin:${PATH}" + BASE_URL="https://github.com/stackql/stackql-deploy-rs/releases/latest/download" + BINARY="stackql-deploy" + case "${OS}-${ARCH}" in + Linux-x86_64|Linux-arm64) + ASSET="stackql-deploy-linux-${ARCH}.tar.gz" ;; + Darwin-x86_64|Darwin-arm64) + ASSET="stackql-deploy-macos-universal.tar.gz" ;; + Windows-x86_64|Windows-arm64) + ASSET="stackql-deploy-windows-${ARCH}.zip" + BINARY="stackql-deploy.exe" + ;; + *) + echo "Unsupported platform: ${OS}-${ARCH}" >&2 + exit 1 + ;; + esac + URL="${BASE_URL}/${ASSET}" + echo "Download URL: ${URL}" + + if [ "$OS" = "Windows" ]; then + curl -fL -o stackql-deploy.zip "$URL" + unzip -o stackql-deploy.zip + rm -f stackql-deploy.zip else - echo "Installing stackql-deploy binary..." - BASE_URL="https://github.com/stackql/stackql-deploy-rs/releases/latest/download" - case "${OS}-${ARCH}" in - Linux-x86_64) URL="${BASE_URL}/stackql-deploy-linux-x86_64.tar.gz" ;; - Linux-aarch64) URL="${BASE_URL}/stackql-deploy-linux-arm64.tar.gz" ;; - MINGW*|MSYS*|CYGWIN*) - URL="${BASE_URL}/stackql-deploy-windows-x86_64.zip" ;; - *) - echo "Unsupported platform: ${OS}-${ARCH}" - exit 1 - ;; - esac - echo "Download URL: ${URL}" - - if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then - curl -fL -o stackql-deploy.zip "$URL" - unzip -o stackql-deploy.zip - rm -f stackql-deploy.zip - else - curl -fL "$URL" | tar -xz - fi - - chmod +x ./stackql-deploy - echo "Binary details: $(file ./stackql-deploy)" - echo "${GITHUB_WORKSPACE}" >> "$GITHUB_PATH" - export PATH="${GITHUB_WORKSPACE}:${PATH}" + curl -fL "$URL" | tar -xz fi + chmod +x "./${BINARY}" + echo "Binary details: $(file "./${BINARY}")" + echo "${GITHUB_WORKSPACE}" >> "$GITHUB_PATH" + PATH="$(pwd):${PATH}" + export PATH + echo "stackql-deploy installed:" - stackql-deploy info + "./${BINARY}" info fi - name: Run stackql-deploy diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..296abc9 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,195 @@ +import io +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest +import zipfile + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +INSTALL = yaml.safe_load((ROOT / "action.yml").read_text(encoding="utf-8"))["runs"]["steps"][0]["run"] +BASE_URL = "https://github.com/stackql/stackql-deploy-rs/releases/latest/download" +BINARY = b'''#!/usr/bin/env bash +printf '%s\n' "$*" >> "$BINARY_LOG" +if [ "$1" = "info" ]; then exit "${MOCK_INFO_STATUS:-0}"; fi +echo 'stackql-deploy 2.3.0' +''' +MOCKS = r''' +export PROCESSOR_ARCHITECTURE="$MOCK_PROCESSOR_ARCHITECTURE" +export PROCESSOR_ARCHITEW6432="$MOCK_PROCESSOR_ARCHITEW6432" +uname() { + case "$1" in + -s) echo "$MOCK_OS" ;; + -m) echo "$MOCK_ARCH" ;; + *) return 1 ;; + esac +} +powershell.exe() { + printf '%s\n' "$*" >> "$POWERSHELL_LOG" + [ -n "$MOCK_WINDOWS_ARCH" ] || return 1 + printf '%s\r\n' "$MOCK_WINDOWS_ARCH" +} +curl() { + local url="${@: -1}" + echo "$url" >> "$DOWNLOAD_LOG" + [ "$MOCK_DOWNLOAD_STATUS" = 0 ] || return "$MOCK_DOWNLOAD_STATUS" + if [ "$2" = '-o' ]; then + cp "$ARCHIVES/${url##*/}" "$3" + else + cat "$ARCHIVES/${url##*/}" + fi +} +if [ -n "${PREINSTALLED_DIR:-}" ]; then + export PATH="$(cd "$PREINSTALLED_DIR" && pwd):$PATH" +fi +''' + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="deploy install ") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.workspace = self.root / "workspace with spaces" + self.workspace.mkdir() + self.archives = self.root / "archives" + self.archives.mkdir() + for platform in ("linux-x86_64", "linux-arm64", "macos-universal"): + with tarfile.open(self.archives / f"stackql-deploy-{platform}.tar.gz", "w:gz") as archive: + member = tarfile.TarInfo("stackql-deploy") + member.size = len(BINARY) + member.mode = 0o644 + archive.addfile(member, io.BytesIO(BINARY)) + for arch in ("x86_64", "arm64"): + with zipfile.ZipFile(self.archives / f"stackql-deploy-windows-{arch}.zip", "w") as archive: + archive.writestr("stackql-deploy.exe", BINARY) + self.script = self.root / "install.sh" + self.script.write_text(MOCKS + INSTALL, encoding="utf-8", newline="\n") + self.env = { + **os.environ, + "PATH": "/usr/bin:/bin", + "GITHUB_WORKSPACE": self.workspace.as_posix(), + "GITHUB_PATH": (self.root / "github_path").as_posix(), + "ARCHIVES": self.archives.as_posix(), + "DOWNLOAD_LOG": (self.root / "downloads").as_posix(), + "BINARY_LOG": (self.root / "binary_calls").as_posix(), + "POWERSHELL_LOG": (self.root / "powershell_calls").as_posix(), + "MOCK_WINDOWS_ARCH": "", + "PROCESSOR_ARCHITECTURE": "", + "PROCESSOR_ARCHITEW6432": "", + "MOCK_DOWNLOAD_STATUS": "0", + "MOCK_INFO_STATUS": "0", + "PREINSTALLED_DIR": "", + } + + def install(self, system, arch, **env): + for name in ("stackql-deploy", "stackql-deploy.exe", "stackql-deploy.zip"): + (self.workspace / name).unlink(missing_ok=True) + return subprocess.run( + [shutil.which("bash"), "--noprofile", "--norc", "-e", "-o", "pipefail", self.script.as_posix()], + cwd=self.workspace, + env={ + **self.env, "MOCK_OS": system, "MOCK_ARCH": arch, **env, + "MOCK_PROCESSOR_ARCHITECTURE": env.get("PROCESSOR_ARCHITECTURE", ""), + "MOCK_PROCESSOR_ARCHITEW6432": env.get("PROCESSOR_ARCHITEW6432", ""), + }, + text=True, + capture_output=True, + ) + + def assert_install(self, system, arch, asset, **env): + result = self.install(system, arch, **env) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual((self.root / "downloads").read_text().splitlines()[-1], f"{BASE_URL}/{asset}") + binary = "stackql-deploy.exe" if asset.endswith(".zip") else "stackql-deploy" + self.assertEqual((self.workspace / binary).read_bytes(), BINARY) + self.assertIn("info", (self.root / "binary_calls").read_text().splitlines()) + self.assertEqual((self.root / "github_path").read_text().splitlines()[-1], self.workspace.as_posix()) + self.assertFalse((self.workspace / "stackql-deploy.zip").exists()) + + def test_linux_and_macos_architectures(self): + for system, arch, platform in ( + ("Linux", "x86_64", "linux-x86_64"), + ("Linux", "amd64", "linux-x86_64"), + ("Linux", "aarch64", "linux-arm64"), + ("Linux", "arm64", "linux-arm64"), + ("Darwin", "x86_64", "macos-universal"), + ("Darwin", "arm64", "macos-universal"), + ): + with self.subTest(system=system, arch=arch): + self.assert_install(system, arch, f"stackql-deploy-{platform}.tar.gz") + self.assertFalse((self.root / "powershell_calls").exists()) + + def test_windows_native_architecture(self): + for system in ("MINGW64_NT-10.0", "MSYS_NT-10.0", "CYGWIN_NT-10.0", "Windows_NT"): + for native, asset_arch in (("AMD64", "x86_64"), ("ARM64", "arm64")): + with self.subTest(system=system, native=native): + self.assert_install( + system, "x86_64", f"stackql-deploy-windows-{asset_arch}.zip", + MOCK_WINDOWS_ARCH=native, + PROCESSOR_ARCHITECTURE="AMD64", + PROCESSOR_ARCHITEW6432="AMD64", + ) + self.assertIn("Get-ItemProperty", (self.root / "powershell_calls").read_text()) + + def test_windows_architecture_fallbacks(self): + for arch, env, expected in ( + ("x86_64", {"PROCESSOR_ARCHITEW6432": "ARM64", "PROCESSOR_ARCHITECTURE": "AMD64"}, "arm64"), + ("x86_64", {"PROCESSOR_ARCHITECTURE": "ARM64"}, "arm64"), + ("x86_64", {"PROCESSOR_ARCHITECTURE": "AMD64"}, "x86_64"), + ("aarch64", {}, "arm64"), + ("x86_64", {}, "x86_64"), + ): + with self.subTest(arch=arch, env=env): + self.assert_install("MINGW64_NT-10.0", arch, f"stackql-deploy-windows-{expected}.zip", **env) + + def test_unsupported_platforms_do_not_download(self): + for system, arch in (("Linux", "riscv64"), ("Darwin", "ppc"), ("MINGW32_NT-10.0", "i686"), ("FreeBSD", "x86_64")): + with self.subTest(system=system, arch=arch): + result = self.install(system, arch) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported platform", result.stderr) + self.assertFalse((self.root / "downloads").exists()) + self.assertFalse((self.root / "github_path").exists()) + + def test_download_failures_stop_installation(self): + for system in ("Linux", "MINGW64_NT-10.0"): + with self.subTest(system=system): + result = self.install(system, "x86_64", MOCK_DOWNLOAD_STATUS="22") + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "binary_calls").exists()) + self.assertFalse((self.root / "github_path").exists()) + + def test_corrupt_archives_stop_installation(self): + for system, asset in (("Linux", "linux-x86_64.tar.gz"), ("MINGW64_NT-10.0", "windows-x86_64.zip")): + with self.subTest(system=system): + (self.archives / f"stackql-deploy-{asset}").write_bytes(b"invalid archive") + result = self.install(system, "x86_64") + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "binary_calls").exists()) + self.assertFalse((self.root / "github_path").exists()) + + def test_info_failure_is_propagated(self): + result = self.install("Linux", "x86_64", MOCK_INFO_STATUS="9") + self.assertEqual(result.returncode, 9, result.stdout + result.stderr) + + def test_existing_install_is_reused(self): + installed = self.root / "existing install" + installed.mkdir() + binary = installed / "stackql-deploy" + binary.write_bytes(BINARY) + binary.chmod(0o755) + result = self.install("Linux", "x86_64", PREINSTALLED_DIR=installed.as_posix()) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("already installed", result.stdout) + self.assertFalse((self.root / "downloads").exists()) + self.assertEqual((self.root / "binary_calls").read_text().splitlines(), ["--version"]) + + +if __name__ == "__main__": + unittest.main()