diff --git a/.github/workflows/stackql-exec-test.yml b/.github/workflows/stackql-exec-test.yml index 4c70969..b71cb4c 100644 --- a/.github/workflows/stackql-exec-test.yml +++ b/.github/workflows/stackql-exec-test.yml @@ -6,10 +6,221 @@ on: - main pull_request: jobs: + unit-tests: + strategy: + fail-fast: false + matrix: + target: + - { os: ubuntu-24.04, arch: x64 } + - { os: ubuntu-24.04-arm, arch: arm64 } + - { os: windows-2025, arch: x64 } + - { os: windows-11-arm, arch: arm64 } + - { os: macos-15-intel, arch: x64 } + - { os: macos-15, arch: arm64 } + runs-on: ${{ matrix.target.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + architecture: ${{ matrix.target.arch }} + - name: Verify native Node architecture + env: + EXPECTED_ARCH: ${{ matrix.target.arch }} + run: node -e "const assert = require('assert/strict'); assert.equal(process.arch, process.env.EXPECTED_ARCH); assert.equal(process.env.RUNNER_ARCH.toLowerCase(), process.env.EXPECTED_ARCH);" + - run: npm ci --ignore-scripts + - run: npm test -- --runInBand + + smoke-tests: + name: Smoke (${{ matrix.target.os }}, ${{ matrix.install }}) + runs-on: ${{ matrix.target.os }} + strategy: + fail-fast: false + matrix: + target: + - { os: ubuntu-24.04, arch: X64 } + - { os: ubuntu-24.04-arm, arch: ARM64 } + - { os: windows-2025, arch: X64 } + - { os: windows-11-arm, arch: ARM64 } + - { os: macos-15-intel, arch: X64 } + - { os: macos-15, arch: ARM64 } + install: [automatic, wrapped, plain] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Verify runner and clean installation + env: + EXPECTED_ARCH: ${{ matrix.target.arch }} + run: | + test "$RUNNER_ARCH" = "$EXPECTED_ARCH" + if command -v stackql; then + echo 'Expected a runner without StackQL installed' + exit 1 + fi + + - name: Preinstall StackQL + if: matrix.install != 'automatic' + uses: stackql/setup-stackql@v2 + with: + use_wrapper: ${{ matrix.install == 'wrapped' }} + + - name: Exercise a Windows wrapper path containing spaces + if: runner.os == 'Windows' && matrix.install == 'wrapped' + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const target = path.join(process.env.RUNNER_TEMP, 'StackQL Tools'); + fs.mkdirSync(target); + for (const name of ['stackql', 'stackql-bin.exe']) { + fs.copyFileSync(path.join(process.env.STACKQL_CLI_PATH, name), path.join(target, name)); + } + core.exportVariable('STACKQL_CLI_PATH', target); + core.addPath(target); + + - name: Execute a multiline query + id: inline + uses: ./ + with: + query: | + SELECT + 'stackql smoke' AS result; + + - name: Verify installation mode + env: + INSTALL_MODE: ${{ matrix.install }} + run: | + if [[ "$RUNNER_OS" == 'macOS' || "$INSTALL_MODE" == 'plain' ]]; then + test -z "${STACKQL_CLI_PATH:-}" + else + test -n "${STACKQL_CLI_PATH:-}" + fi + + - name: Verify native Windows binary architecture + if: runner.os == 'Windows' + shell: pwsh + run: | + $binary = if ($env:STACKQL_CLI_PATH) { + Join-Path $env:STACKQL_CLI_PATH 'stackql-bin.exe' + } else { + (Get-Command stackql.exe).Source + } + $bytes = [IO.File]::ReadAllBytes($binary) + $pe = [BitConverter]::ToInt32($bytes, 0x3c) + $machine = [BitConverter]::ToUInt16($bytes, $pe + 4) + $expected = if ($env:RUNNER_ARCH -eq 'ARM64') { 0xaa64 } else { 0x8664 } + if ($machine -ne $expected) { throw 'Installed binary does not match runner architecture' } + + - name: Verify native Linux or macOS binary architecture + if: runner.os != 'Windows' + run: | + binary="$(command -v stackql)" + if [[ -n "${STACKQL_CLI_PATH:-}" ]]; then + binary="$STACKQL_CLI_PATH/stackql-bin" + fi + if [[ "$RUNNER_OS" == 'macOS' ]]; then + arch=x86_64 + if [[ "$RUNNER_ARCH" == 'ARM64' ]]; then arch=arm64; fi + lipo "$binary" -verify_arch "$arch" + else + arch='x86-64' + if [[ "$RUNNER_ARCH" == 'ARM64' ]]; then arch='ARM aarch64'; fi + file "$binary" | grep -E "ELF 64-bit.*$arch" + fi + + - name: Create query and data files in a path containing spaces + id: fixtures + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const dir = path.join(process.env.RUNNER_TEMP, 'StackQL queries'); + fs.mkdirSync(dir); + const query = path.join(dir, 'query file.iql'); + const data = path.join(dir, 'query data.jsonnet'); + fs.writeFileSync(query, "SELECT '{{ .value }}' AS result;"); + fs.writeFileSync(data, '{value: std.extVar("VALUE")}'); + core.setOutput('query', query); + core.setOutput('data', data); + + - name: Execute a query file with data and variables + id: file + uses: ./ + with: + query_file_path: ${{ steps.fixtures.outputs.query }} + data_file_path: ${{ steps.fixtures.outputs.data }} + vars: VALUE=stackql smoke + + - name: Dry run a query file with data and variables + id: dryrun + uses: ./ + with: + query_file_path: ${{ steps.fixtures.outputs.query }} + data_file_path: ${{ steps.fixtures.outputs.data }} + vars: VALUE=stackql smoke + dry_run: true + + - name: Execute a CSV query + id: csv + uses: ./ + with: + query: SELECT 'stackql smoke' AS result; + query_output: csv + + - name: Execute a table query + id: table + uses: ./ + with: + query: SELECT 'stackql smoke' AS result; + query_output: table + + - name: Execute a text query + id: text + uses: ./ + with: + query: SELECT 'stackql smoke' AS result; + query_output: text + + - name: Assert action outputs + uses: actions/github-script@v8 + env: + RESULTS: ${{ toJSON(steps) }} + with: + script: | + const assert = require('assert/strict'); + const steps = JSON.parse(process.env.RESULTS); + const output = id => steps[id].outputs['stackql-query-results']; + for (const id of ['inline', 'file', 'dryrun', 'csv', 'table', 'text']) { + assert.ok(output(id), `${id} returned no results`); + assert.ok(!steps[id].outputs['stackql-query-error'], `${id} returned an error`); + } + for (const id of ['inline', 'file']) { + const rows = JSON.parse(output(id)); + assert.equal(rows.length, 1); + const value = rows[0].result; + if (value !== null && typeof value === 'object') { + assert.deepEqual(value, {String: 'stackql smoke', Valid: true}); + } else { + assert.equal(value, 'stackql smoke'); + } + } + assert.deepEqual(JSON.parse(output('dryrun')), [{query: "SELECT 'stackql smoke' AS result;"}]); + for (const id of ['csv', 'text']) { + assert.equal(output(id).replace(/\r\n/g, '\n').trim(), 'result\nstackql smoke'); + } + assert.match(output('table'), /\|\s*result\s*\|/); + assert.match(output('table'), /\|\s*stackql smoke\s*\|/); + stackql-exec-google-example: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-24.04, ubuntu-24.04-arm, windows-2025, windows-11-arm, macos-15-intel, macos-15] runs-on: ${{matrix.os}} name: 'StackQL exec Google ' @@ -169,4 +380,4 @@ jobs: echo "${{ steps.stackql-query-file-with-vars.outputs.stackql-query-results }}" echo "stackql-query-file-with-data-file-and-vars:" - echo "${{ steps.stackql-query-file-with-data-file-and-vars.outputs.stackql-query-results }}" \ No newline at end of file + echo "${{ steps.stackql-query-file-with-data-file-and-vars.outputs.stackql-query-results }}" diff --git a/README.md b/README.md index 6aee96c..dc7ee3e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,20 @@ Authentication to StackQL providers is done via environment variables source fro > This action uses [setup-stackql](https://github.com/marketplace/actions/setup-stackql) +Windows ARM64 runners use the native binary installed by `setup-stackql`. Existing plain `stackql.exe` installations on `PATH` and wrapped installations are both supported. + +## Supported platforms + +| Platform | x64 runner | ARM64 runner | +| --- | --- | --- | +| Linux | `ubuntu-24.04` | `ubuntu-24.04-arm` | +| Windows | `windows-2025` | `windows-11-arm` | +| macOS | `macos-15-intel` | `macos-15` | + +CI runs unit tests and integration tests on all six combinations. Smoke tests need no cloud credentials and cover automatic setup, preinstalled wrapped binaries, and preinstalled plain binaries. On macOS, `setup-stackql` uses Homebrew and does not install a wrapper. + +Smoke tests verify the native binary architecture, multiline queries, query and data paths containing spaces, variables, dry runs, and all four output formats. Provider integration tests additionally use the repository's GitHub and Google credentials. + ## Examples ### Inline `stackql` query example @@ -130,6 +144,6 @@ workflow excerpt: To run unit tests locally against this action, use the following: ``` -npm i -npm run test lib/tests/utils.test.js +npm ci --ignore-scripts +npm test -- --runInBand ``` diff --git a/lib/tests/platforms.test.js b/lib/tests/platforms.test.js new file mode 100644 index 0000000..77c187a --- /dev/null +++ b/lib/tests/platforms.test.js @@ -0,0 +1,132 @@ +const fs = require('fs'); +const childProcess = require('child_process'); +const { promisify } = require('util'); + +const platforms = ['linux', 'darwin', 'win32'].flatMap(platform => + ['x64', 'arm64'].map(arch => [platform, arch])); +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalArch = Object.getOwnPropertyDescriptor(process, 'arch'); +const originalEnv = process.env; + +describe.each(platforms)('%s/%s', (platform, arch) => { + let core; + let utils; + let execute; + const executable = platform === 'win32' ? 'stackql.exe' : 'stackql'; + + beforeEach(() => { + process.env = { ...originalEnv }; + for (const name of ['QUERY', 'QUERY_FILE_PATH', 'DATA_FILE_PATH', 'DRY_RUN', 'AUTH', 'VARS', 'STACKQL_CLI_PATH']) { + delete process.env[name]; + } + process.env.OUTPUT = 'json'; + Object.defineProperty(process, 'platform', { value: platform }); + Object.defineProperty(process, 'arch', { value: arch }); + core = Object.fromEntries(['debug', 'info', 'error', 'warning', 'setFailed', 'setOutput'].map(name => [name, jest.fn()])); + execute = jest.fn(); + jest.spyOn(childProcess, 'exec')[promisify.custom] = execute; + jest.resetModules(); + utils = require('../utils'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', originalPlatform); + Object.defineProperty(process, 'arch', originalArch); + process.env = originalEnv; + jest.resetModules(); + }); + + it.each(['json', 'csv', 'table', 'text'])('builds a multiline %s query for the platform shell', async format => { + process.env.QUERY = "SELECT\n 'stackql smoke' AS result;"; + process.env.OUTPUT = format; + await utils.getStackqlCommand(core); + const query = platform === 'win32' ? "SELECT 'stackql smoke' AS result;" : process.env.QUERY; + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', `${executable} exec "${query}" --output ${format}`); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('quotes query and data paths and includes variables and dry-run flags', async () => { + process.env.QUERY_FILE_PATH = 'queries with spaces/query.iql'; + process.env.DATA_FILE_PATH = 'queries with spaces/data.jsonnet'; + process.env.VARS = 'VALUE=stackql smoke'; + process.env.DRY_RUN = 'true'; + jest.spyOn(fs, 'existsSync').mockImplementation(file => + [process.env.QUERY_FILE_PATH, process.env.DATA_FILE_PATH].includes(file)); + jest.resetModules(); + await require('../utils').getStackqlCommand(core); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', + `${executable} exec -i "queries with spaces/query.iql" --iqldata "queries with spaces/data.jsonnet" --output json --dryrun --var "VALUE=stackql smoke"`); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + if (platform !== 'win32') { + it('uses PATH for both plain and wrapped Unix installations', async () => { + process.env.STACKQL_CLI_PATH = '/tmp/StackQL Tools'; + process.env.QUERY = 'SELECT 1'; + await utils.getStackqlCommand(core); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', 'stackql exec "SELECT 1" --output json'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + } + + it.each(['json', 'csv', 'table', 'text'])('preserves raw %s query output', async format => { + const outputs = { + json: '[{"result":"stackql smoke"}]', + csv: 'result\nstackql smoke\n', + table: '| result |\n| stackql smoke |\n', + text: 'result\nstackql smoke\n', + }; + execute.mockResolvedValue({ stdout: outputs[format], stderr: '' }); + await utils.execStackQLQuery(core, 'test command', false, 'exit', false); + expect(execute).toHaveBeenCalledWith('test command'); + expect(core.setOutput).toHaveBeenCalledWith('stackql-query-results', outputs[format]); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + if (platform !== 'win32') { + it('extracts query output from the setup-stackql wrapper', async () => { + const result = '[\n{"result":"stackql smoke"}\n]'; + execute.mockResolvedValue({ + stdout: `[command]/tmp/stackql-bin exec query\n${result}\n::debug::stdout: ${result.replace(/\n/g, '%0A')}\n::debug::stderr: \n`, + stderr: '', + }); + await utils.execStackQLQuery(core, 'test command', false, 'exit', false); + expect(core.setOutput).toHaveBeenCalledWith('stackql-query-results', result); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('extracts command output from the setup-stackql wrapper', async () => { + execute.mockResolvedValue({ stdout: '::debug::stdout: \n::debug::stderr: provider installed%0A\n', stderr: 'provider installed\n' }); + await utils.execStackQLQuery(core, 'test command', true, 'exit', false); + expect(core.setOutput).toHaveBeenCalledWith('stackql-command-output', 'provider installed'); + expect(core.setOutput).not.toHaveBeenCalledWith('stackql-query-results', expect.anything()); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + } + + it('captures native command output from stderr', async () => { + execute.mockResolvedValue({ stdout: '', stderr: 'provider installed\n' }); + await utils.execStackQLQuery(core, 'test command', true, 'exit', false); + expect(core.setOutput).toHaveBeenCalledWith('stackql-command-output', 'provider installed\n'); + expect(core.setOutput).not.toHaveBeenCalledWith('stackql-query-results', expect.anything()); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('returns the rendered query during a dry run', async () => { + const stdout = '[{"query":"SELECT 1;"}]'; + execute.mockResolvedValue({ stdout, stderr: '' }); + await utils.execStackQLQuery(core, 'test command --dryrun', false, 'exit', true); + expect(execute).toHaveBeenCalledWith('test command --dryrun'); + expect(core.setOutput).toHaveBeenCalledWith('stackql-query-results', stdout); + expect(core.info).toHaveBeenCalledWith('dry-run query:\nSELECT 1;'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('fails the action when the executable fails', async () => { + execute.mockRejectedValue(new Error('process exited with code 1')); + await utils.execStackQLQuery(core, 'test command', false, 'exit', false); + expect(core.setFailed).toHaveBeenCalledWith('stackql command failed with error: process exited with code 1'); + expect(core.setOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/tests/utils.test.js b/lib/tests/utils.test.js index 02ed6e0..34e96fc 100644 --- a/lib/tests/utils.test.js +++ b/lib/tests/utils.test.js @@ -1,9 +1,14 @@ const { getStackqlCommand, setupAuth } = require("../utils"); +const fs = require('fs'); + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalArch = Object.getOwnPropertyDescriptor(process, 'arch'); describe("Utils Functions Tests", () => { let core; beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); core = { debug: jest.fn().mockImplementation((message) => console.log(`DEBUG: ${message}`)), @@ -22,11 +27,15 @@ describe("Utils Functions Tests", () => { delete process.env.DATA_FILE_PATH; delete process.env.VARS; delete process.env.AUTH; + delete process.env.STACKQL_CLI_PATH; process.env.OUTPUT = 'json'; }); afterEach(() => { - jest.resetAllMocks(); + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', originalPlatform); + Object.defineProperty(process, 'arch', originalArch); + delete process.env.STACKQL_CLI_PATH; }); it("should handle a stackql query", () => { @@ -103,6 +112,36 @@ describe("Utils Functions Tests", () => { }); + describe.each(['x64', 'arm64'])('Windows %s installs', arch => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + Object.defineProperty(process, 'arch', { value: arch }); + process.env.QUERY = 'SELECT 1 AS result'; + }); + + it('uses a plain installation on PATH', async () => { + await getStackqlCommand(core); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', 'stackql.exe exec "SELECT 1 AS result" --output json'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('quotes the wrapped binary path', async () => { + process.env.STACKQL_CLI_PATH = 'C:\\StackQL Tools'; + jest.spyOn(fs, 'existsSync').mockImplementation(file => file === 'C:\\StackQL Tools\\stackql-bin.exe'); + jest.resetModules(); + await require('../utils').getStackqlCommand(core); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', '"C:\\StackQL Tools\\stackql-bin.exe" exec "SELECT 1 AS result" --output json'); + }); + + it('falls back to PATH when wrapper metadata is stale', async () => { + process.env.STACKQL_CLI_PATH = 'C:\\missing'; + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + jest.resetModules(); + await require('../utils').getStackqlCommand(core); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', 'stackql.exe exec "SELECT 1 AS result" --output json'); + }); + }); + // auth is emitted as a scoped step output, never exported to GITHUB_ENV, // so it cannot carry over between steps/actions in a job. describe("setupAuth", () => { diff --git a/lib/utils.js b/lib/utils.js index 51ecdda..646bd61 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -114,7 +114,10 @@ async function getStackqlCommand(core) { args.push(`--var "${vars}"`); } - const stackQLExecutable = isWindows ? `${process.env.STACKQL_CLI_PATH}\\stackql-bin.exe` : `stackql`; + const wrappedBinary = process.env.STACKQL_CLI_PATH && `${process.env.STACKQL_CLI_PATH}\\stackql-bin.exe`; + const stackQLExecutable = isWindows + ? (wrappedBinary && existsSync(wrappedBinary) ? `"${wrappedBinary}"` : 'stackql.exe') + : 'stackql'; try { const stackQLCommand = `${stackQLExecutable} ${args.join(" ")}`;