From d0dafc40974223a84cd14f4b19c75b3844741ca3 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 14 Mar 2026 16:54:27 +1100 Subject: [PATCH 1/6] updated setup-stackql --- action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index 934eef1..7c70fcc 100644 --- a/action.yml +++ b/action.yml @@ -60,7 +60,7 @@ runs: fi - name: Setup StackQL - uses: stackql/setup-stackql@v2.2.3 + uses: stackql/setup-stackql@v2.3.0 if: ${{steps.check-stackql.outputs.stackql_installed == 'false'}} with: use_wrapper: true @@ -72,7 +72,7 @@ runs: - name: Setup auth id: setup-auth - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8 with: script: | const path = require('path'); @@ -84,7 +84,7 @@ runs: AUTH_STR: ${{inputs.auth_str}} - name: get stackql command - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8 with: script: | const path = require('path'); @@ -100,7 +100,7 @@ runs: VARS: ${{inputs.vars}} - name: execute stackql command - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8 id: exec-stackql with: script: | From 66694795beb50231da4eb1b408800487194dc438 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sat, 14 Mar 2026 17:06:00 +1100 Subject: [PATCH 2/6] updated action versions --- .github/workflows/npm-test.yml | 7 +++---- .github/workflows/stackql-exec-test.yml | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index 0193f73..837459c 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -7,11 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.3 + - uses: actions/checkout@v6 - name: Setup Node.js environment - uses: actions/setup-node@v4.1.0 + uses: actions/setup-node@v6 with: node-version: 18 - run: npm ci - - run: npm test - \ No newline at end of file + - run: npm test \ No newline at end of file diff --git a/.github/workflows/stackql-exec-test.yml b/.github/workflows/stackql-exec-test.yml index 9f23d7c..4c70969 100644 --- a/.github/workflows/stackql-exec-test.yml +++ b/.github/workflows/stackql-exec-test.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.1.3 + uses: actions/checkout@v6 # # run a query that does not return data (using the `is_command` input) From 8cd1af42dcf5ed695cb78a77adfaf6c837fcfc7c Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Sun, 15 Mar 2026 09:39:52 +1100 Subject: [PATCH 3/6] use latest setup-stackql --- .github/workflows/npm-test.yml | 2 +- action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index 837459c..e62b885 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -11,6 +11,6 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 - run: npm ci - run: npm test \ No newline at end of file diff --git a/action.yml b/action.yml index 7c70fcc..9164ece 100644 --- a/action.yml +++ b/action.yml @@ -60,7 +60,7 @@ runs: fi - name: Setup StackQL - uses: stackql/setup-stackql@v2.3.0 + uses: stackql/setup-stackql@v2 if: ${{steps.check-stackql.outputs.stackql_installed == 'false'}} with: use_wrapper: true From edeaa2e3010aab5bafbe75dd5011ed04319d22b8 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Mon, 20 Jul 2026 18:41:34 +1000 Subject: [PATCH 4/6] Scope auth and command to step outputs, not GITHUB_ENV setupAuth and getStackqlCommand previously wrote AUTH and STACKQL_COMMAND via core.exportVariable, which persists through GITHUB_ENV to every later step in a job. That let auth/args from one action invocation carry into the next (e.g. github bearer auth being applied to a later cloudflare query). Emit both as step outputs (core.setOutput) and wire them through action.yml via steps..outputs, so they are scoped to this invocation and cannot leak across steps or actions. Tests updated to the setOutput contract with added setupAuth coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- action.yml | 10 ++++++++-- lib/tests/utils.test.js | 41 ++++++++++++++++++++++++++++++++--------- lib/utils.js | 18 +++++++++++++----- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/action.yml b/action.yml index 9164ece..aa8aa8d 100644 --- a/action.yml +++ b/action.yml @@ -84,6 +84,7 @@ runs: AUTH_STR: ${{inputs.auth_str}} - name: get stackql command + id: get-command uses: actions/github-script@v8 with: script: | @@ -98,7 +99,10 @@ runs: DRY_RUN: ${{inputs.dry_run}} OUTPUT: ${{inputs.query_output}} VARS: ${{inputs.vars}} - + # Scoped to this invocation via the setup-auth step output - not GITHUB_ENV - + # so auth never carries over from another step in the job. + AUTH: ${{ steps.setup-auth.outputs.auth }} + - name: execute stackql command uses: actions/github-script@v8 id: exec-stackql @@ -110,9 +114,11 @@ runs: const onFailure = process.env.ON_FAILURE || 'exit'; // default to 'exit' if not specified await execStackQLQuery(core, process.env.STACKQL_COMMAND, process.env.IS_COMMAND === 'true', onFailure, process.env.DRY_RUN === 'true'); env: + # Consumed from the get-command step output, not GITHUB_ENV. + STACKQL_COMMAND: ${{ steps.get-command.outputs.stackql_command }} IS_COMMAND: ${{ inputs.is_command }} DRY_RUN: ${{ inputs.dry_run }} - ON_FAILURE: ${{ inputs.on_failure }} + ON_FAILURE: ${{ inputs.on_failure }} branding: icon: 'terminal' diff --git a/lib/tests/utils.test.js b/lib/tests/utils.test.js index 759495c..02ed6e0 100644 --- a/lib/tests/utils.test.js +++ b/lib/tests/utils.test.js @@ -1,4 +1,4 @@ -const { getStackqlCommand } = require("../utils"); +const { getStackqlCommand, setupAuth } = require("../utils"); describe("Utils Functions Tests", () => { let core; @@ -10,9 +10,10 @@ describe("Utils Functions Tests", () => { setFailed: jest.fn().mockImplementation((message) => console.error(message)), info: jest.fn().mockImplementation((message) => console.log(message)), exportVariable: jest.fn(), + setOutput: jest.fn(), error: jest.fn().mockImplementation((message) => console.error(message)), }; - jest.spyOn(core, 'exportVariable').mockImplementation((name, value) => { + jest.spyOn(core, 'setOutput').mockImplementation((name, value) => { core[name] = value; }); // set defaults for env vars @@ -39,7 +40,7 @@ describe("Utils Functions Tests", () => { const expectedCommand = 'stackql exec "SELECT status, count(*) as num_instances FROM google.compute.instances WHERE project = \'stackql-demo\' GROUP BY status" --output json'; // Verify that the full command is set correctly - expect(core.exportVariable).toHaveBeenCalledWith('STACKQL_COMMAND', expectedCommand); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', expectedCommand); // Optionally, check for no errors expect(core.error).not.toHaveBeenCalled(); @@ -52,16 +53,16 @@ describe("Utils Functions Tests", () => { getStackqlCommand(core); // Use RegExp for case insensitive comparison expect(core.setFailed).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`query file path does not exist: ${process.env.QUERY_FILE_PATH}`, 'i'))); - expect(core.exportVariable).not.toHaveBeenCalledWith('STACKQL_COMMAND', expect.any(String)); + expect(core.setOutput).not.toHaveBeenCalledWith('stackql_command', expect.any(String)); }); - + // Test for unsupported output formats it('should handle unsupported output formats', () => { process.env.OUTPUT = 'unsupported_format'; getStackqlCommand(core); // Use RegExp for case insensitive comparison expect(core.setFailed).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`output format not supported: ${process.env.OUTPUT}`, 'i'))); - expect(core.exportVariable).not.toHaveBeenCalled(); + expect(core.setOutput).not.toHaveBeenCalled(); }); it("should handle query execution from an existing file", () => { @@ -75,7 +76,7 @@ describe("Utils Functions Tests", () => { const expectedCommand = 'stackql exec -i "stackql_scripts/google-instances-by-status.iql" --output json'; // Verify that the command is set correctly - expect(core.exportVariable).toHaveBeenCalledWith('STACKQL_COMMAND', expectedCommand); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', expectedCommand); // Optionally, check for no errors expect(core.error).not.toHaveBeenCalled(); @@ -94,12 +95,34 @@ describe("Utils Functions Tests", () => { const expectedCommand = 'stackql exec -i "stackql_scripts/google-instances-by-status-with-external-data-file.iql" --iqldata "stackql_scripts/google-instances-by-status-with-external-data-file.jsonnet" --output json'; // Verify that the command is set correctly - expect(core.exportVariable).toHaveBeenCalledWith('STACKQL_COMMAND', expectedCommand); + expect(core.setOutput).toHaveBeenCalledWith('stackql_command', expectedCommand); // Optionally, check for no errors expect(core.error).not.toHaveBeenCalled(); expect(core.setFailed).not.toHaveBeenCalled(); }); - + + + // 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", () => { + afterEach(() => { + delete process.env.AUTH_STR; + delete process.env.AUTH_FILE_PATH; + }); + + it("emits the provided auth string as the 'auth' output", () => { + process.env.AUTH_STR = '{ "cloudflare": { "type": "bearer", "credentialsenvvar": "CLOUDFLARE_API_TOKEN" } }'; + setupAuth(core); + expect(core.setOutput).toHaveBeenCalledWith('auth', process.env.AUTH_STR); + expect(core.exportVariable).not.toHaveBeenCalled(); + }); + + it("emits an empty 'auth' output when no auth is supplied (no carry-over)", () => { + setupAuth(core); + expect(core.setOutput).toHaveBeenCalledWith('auth', ''); + expect(core.exportVariable).not.toHaveBeenCalled(); + }); + }); }); diff --git a/lib/utils.js b/lib/utils.js index 3b4a422..51ecdda 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,8 +4,10 @@ const execAsync = promisify(require('child_process').exec); /** * Sets up authentication by reading from either a file or a string. - * The AUTH environment variable is set with the authentication details. - * @param {*} core - GitHub core library for setting outputs and environment variables. + * The resolved auth is emitted as this step's `auth` output (NOT exported to + * GITHUB_ENV), so it is scoped to this action invocation and cannot leak into + * later steps/actions in the same job. + * @param {*} core - GitHub core library for setting outputs. */ function setupAuth(core) { let auth; @@ -15,6 +17,9 @@ function setupAuth(core) { // Check if any authentication method is provided if (!checkEnvVarValid(fileName) && !checkEnvVarValid(authStr)) { core.info("using default provider environment variable variables as AUTH_FILE_PATH or AUTH_STR are not set..."); + // Emit an empty auth output so downstream steps receive a clean value for + // this invocation rather than anything carried over from a previous one. + core.setOutput("auth", ""); return; } @@ -32,8 +37,8 @@ function setupAuth(core) { auth = authStr; } - core.info("setting AUTH environment variable..."); - core.exportVariable("AUTH", auth); + core.info("setting auth output..."); + core.setOutput("auth", auth); } async function getStackqlCommand(core) { @@ -113,7 +118,10 @@ async function getStackqlCommand(core) { try { const stackQLCommand = `${stackQLExecutable} ${args.join(" ")}`; - core.exportVariable('STACKQL_COMMAND', `${stackQLCommand}`); + // Emit as a step output (scoped to this invocation) rather than exporting to + // GITHUB_ENV, so the assembled command - which may embed auth/secrets - does + // not persist into later steps or actions in the same job. + core.setOutput('stackql_command', `${stackQLCommand}`); core.debug(`STACKQL_COMMAND: ${stackQLCommand}`); } catch (error) { core.error(error); From f427598874514f4177b28371fdb9ea3910b2c70c Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Tue, 13 Oct 2026 07:05:09 +1100 Subject: [PATCH 5/6] added win arm64 --- .github/workflows/stackql-exec-test.yml | 215 +++++++++++++++++++++++- README.md | 18 +- lib/tests/platforms.test.js | 132 +++++++++++++++ lib/tests/utils.test.js | 41 ++++- lib/utils.js | 5 +- 5 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 lib/tests/platforms.test.js 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(" ")}`; From beb329e5e36146ab0a7084fb3f7ba66f42b804e1 Mon Sep 17 00:00:00 2001 From: Jeffrey Aven Date: Tue, 13 Oct 2026 15:44:02 +1100 Subject: [PATCH 6/6] updated readme --- CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ LICENSE | 2 +- README.md | 19 ++++++++++++++++++- SECURITY.md | 12 ++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3c1ab27 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Before contributing, read [README.md](./README.md) for project scope. + +## Setup + +1. Fork and clone the repository, then create a branch for your change. +2. Install Node.js 24, matching CI. +3. From the repository root, install dependencies and run the unit tests: + + ```sh + npm ci --ignore-scripts + npm test -- --runInBand + ``` + +The unit tests use Jest and mock StackQL execution. They do not require a StackQL installation or cloud credentials. + +## Project layout + +- [action.yml](./action.yml): composite action inputs, outputs, and steps. +- [lib/utils.js](./lib/utils.js): authentication setup, command construction, and execution. +- [lib/tests](./lib/tests): unit tests, including platform-specific behavior. +- [stackql_scripts](./stackql_scripts): query and data files used by provider integration tests. + +## CI tests + +The [test workflow](./.github/workflows/stackql-exec-test.yml) runs unit tests and smoke tests on Linux, Windows, and macOS, for both x64 and ARM64. Smoke tests require no cloud credentials and cover automatic setup, preinstalled wrapped binaries, and preinstalled plain binaries. On macOS, setup uses Homebrew without a wrapper. + +Provider integration tests additionally require the `STACKQL_GITHUB_USERNAME`, `STACKQL_GITHUB_PASSWORD`, and `GOOGLE_CREDENTIALS` repository secrets, plus the `GOOGLE_PROJECT` and `GOOGLE_ZONE` repository variables. Repository secrets are unavailable to [pull request workflows from forks](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflows-in-forked-repositories), so provider tests may fail there because credentials are missing. + +## Pull requests + +- Keep changes focused on one feature or fix. +- Include tests with functional changes, including platform-specific cases where relevant. +- Update [README.md](./README.md) when changing inputs, outputs, usage, or supported platforms. +- Describe the change and the tests run, including any CI failures caused by unavailable credentials. +- Preserve input validation and authentication isolation between action invocations. + +## Security reports + +Follow [SECURITY.md](./SECURITY.md) to report suspected vulnerabilities privately. Do not include credentials in issues, pull requests, or test fixtures. diff --git a/LICENSE b/LICENSE index a57858b..aa75be6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 StackQL Studios +Copyright (c) 2026 StackQL Studios Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index dc7ee3e..12503b9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,21 @@ -[![StackQL Exec](https://github.com/stackql/stackql-exec/actions/workflows/stackql-exec-test.yml/badge.svg)](https://github.com/stackql/stackql-exec/actions/workflows/stackql-exec-test.yml) +[stars]: https://img.shields.io/github/stars/stackql/stackql?style=flat-square "GitHub Stars" +[logo]: https://stackql.io/img/stackql-logo-bold.png "stackql logo" +[stackql]: https://github.com/stackql/stackql +[homepage]: https://stackql.io/ +[downloads]: https://img.shields.io/github/downloads/stackql/stackql/total?label=github%20downloads + +
+ +[![logo]][homepage] + +[![Marketplace](https://img.shields.io/badge/Marketplace-stackql--exec-blue?logo=github)](https://github.com/marketplace/actions/stackql-exec) +[![Release](https://img.shields.io/github/v/release/stackql/stackql-exec)](https://github.com/stackql/stackql-exec/releases) +[![CI](https://github.com/stackql/stackql-exec/actions/workflows/stackql-exec-test.yml/badge.svg)](https://github.com/stackql/stackql-exec/actions/workflows/stackql-exec-test.yml) +[![License](https://img.shields.io/github/license/stackql/stackql-exec)](LICENSE) +![downloads] +[![stars]][stackql] + +
# stackql-exec Github Action as a wrapper for executing a single command in stackql, maps all stackql exec args to actions args diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f27e902 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security policy + +Please report security vulnerabilities privately to StackQL Studios. + +Do not open public issues for suspected vulnerabilities in this repository or in findings produced by this tool. + +Use one of the private reporting channels below: + +- Email: security@stackql.io +- GitHub private vulnerability report for this repository + +When reporting, include reproduction details, affected version, and impact.