Compare commits

..

6 Commits

Author SHA1 Message Date
Julien HENRY 713881670b SQSCANGHA-127 Rename downloaded file to .zip before extraction on Windows (#251)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:51:37 +02:00
Julien HENRY 3581139216 SQSCANGHA-135 Fix scanner binaries always re-downloaded due to incompatible 4-part version (#250)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:53:34 +02:00
Julien HENRY c9d327c024 SQSCANGHA-84 Remove outdated wget/curl references
The action was refactored to use Node.js (@actions/tool-cache) for
downloads, which doesn't rely on wget or curl. Update the README and
QA workflow to reflect this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:25:27 +02:00
Julien HENRY b243e5198f SQSCANGHA-88 Deprecate the SONARCLOUD_URL env variable support
Emit a warning when SONARCLOUD_URL is set, directing users to either
pass nothing, use SONAR_REGION=us for the US region, or pass
-Dsonar.scanner.sonarcloudUrl and -Dsonar.scanner.apiBaseUrl via args
for advanced needs. Backward compatibility is preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:34:28 +02:00
Julien HENRY 375c3f5c03 SQSCANGHA-149 Add scannerBinariesAuthHeader input for authenticated binary downloads
Organisations using private Artifactory mirrors require authentication to
download the SonarScanner CLI. This adds an optional scannerBinariesAuthHeader
input whose value is forwarded as the Authorization HTTP header to both the
binary and GPG signature downloads via tc.downloadTool's built-in auth
parameter. No new dependencies are introduced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:19:55 +02:00
Julien HENRY 9c783232fe SQSCANGHA-144 Add gate jobs to QA workflows for branch protection
Add a non-matrix gate job to qa-main, qa-deprecated-c-cpp, and
qa-install-build-wrapper workflows. Each gate job depends on all
other jobs in its workflow and provides a single stable check context
that can be used in GitHub branch protection required status checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:32:35 +02:00
14 changed files with 762 additions and 48 deletions
+14
View File
@@ -88,3 +88,17 @@ jobs:
BINARY: ${{ steps.run-action.outputs.build-wrapper-binary }} BINARY: ${{ steps.run-action.outputs.build-wrapper-binary }}
run: | run: |
("$BINARY" || true) | grep "build-wrapper, version " ("$BINARY" || true) | grep "build-wrapper, version "
qa-gate:
name: QA Deprecated C and C++ - gate
runs-on: ubuntu-latest
needs: [output-test]
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All checks passed."
@@ -70,3 +70,17 @@ jobs:
BINARY: ${{ steps.run-action.outputs.build-wrapper-binary }} BINARY: ${{ steps.run-action.outputs.build-wrapper-binary }}
run: | run: |
("$BINARY" || true) | grep "build-wrapper, version " ("$BINARY" || true) | grep "build-wrapper, version "
qa-gate:
name: QA Install Build Wrapper - gate
runs-on: ubuntu-latest
needs: [output-test]
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All checks passed."
+45 -24
View File
@@ -245,9 +245,9 @@ jobs:
- name: Assert Sonar Scanner CLI was not executed - name: Assert Sonar Scanner CLI was not executed
run: | run: |
./test/assertFileDoesntExist ./output.properties ./test/assertFileDoesntExist ./output.properties
scannerBinariesUrlIsEscapedWithWget: scannerBinariesUrlCommandInjectionTest:
name: > name: >
'scannerBinariesUrl' is escaped with wget so special chars are not injected in the download command 'scannerBinariesUrl' does not allow command injection via semicolons
runs-on: github-ubuntu-latest-s runs-on: github-ubuntu-latest-s
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -266,22 +266,14 @@ jobs:
- name: Assert file.txt does not exist - name: Assert file.txt does not exist
run: | run: |
./test/assertFileDoesntExist "$RUNNER_TEMP/sonarscanner/file.txt" ./test/assertFileDoesntExist "$RUNNER_TEMP/sonarscanner/file.txt"
scannerBinariesUrlIsEscapedWithCurl: scannerBinariesUrlCommandInjectionWithSpacesTest:
name: > name: >
'scannerBinariesUrl' is escaped with curl so special chars are not injected in the download command 'scannerBinariesUrl' does not allow command injection via spaces and quotes
runs-on: github-ubuntu-latest-s runs-on: github-ubuntu-latest-s
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Remove wget
run: sudo apt-get remove -y wget
- name: Assert wget is not available
run: |
if command -v wget 2>&1 >/dev/null
then
exit 1
fi
- name: Run action with scannerBinariesUrl - name: Run action with scannerBinariesUrl
id: runTest id: runTest
uses: ./ uses: ./
@@ -451,7 +443,7 @@ jobs:
./test/assertFileExists ./test/example-project/.scannerwork/report-task.txt ./test/assertFileExists ./test/example-project/.scannerwork/report-task.txt
overrideSonarcloudUrlTest: overrideSonarcloudUrlTest:
name: > name: >
'SONARCLOUD_URL' is used Deprecated 'SONARCLOUD_URL' still works and emits a deprecation warning
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -461,7 +453,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Run action with SONARCLOUD_URL - name: Run action with deprecated SONARCLOUD_URL
uses: ./ uses: ./
with: with:
args: -Dsonar.scanner.apiBaseUrl=api.mirror.sonarcloud.io -Dsonar.scanner.internal.dumpToFile=./output.properties args: -Dsonar.scanner.apiBaseUrl=api.mirror.sonarcloud.io -Dsonar.scanner.internal.dumpToFile=./output.properties
@@ -472,22 +464,14 @@ jobs:
run: | run: |
./test/assertFileContains ./output.properties "sonar.host.url=mirror.sonarcloud.io" ./test/assertFileContains ./output.properties "sonar.host.url=mirror.sonarcloud.io"
./test/assertFileContains ./output.properties "sonar.scanner.sonarcloudUrl=mirror.sonarcloud.io" ./test/assertFileContains ./output.properties "sonar.scanner.sonarcloudUrl=mirror.sonarcloud.io"
curlPerformsRedirect: scannerBinariesUrlRedirectFollowed:
name: > name: >
curl performs redirect when scannerBinariesUrl returns 3xx scannerBinariesUrl redirect (3xx) is followed
runs-on: github-ubuntu-latest-s runs-on: github-ubuntu-latest-s
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Remove wget
run: sudo apt-get remove -y wget
- name: Assert wget is not available
run: |
if command -v wget 2>&1 >/dev/null
then
exit 1
fi
- name: Generate SSL certificates for nginx - name: Generate SSL certificates for nginx
run: ./generate-ssl.sh run: ./generate-ssl.sh
working-directory: .github/qa-nginx-redirecting working-directory: .github/qa-nginx-redirecting
@@ -827,3 +811,40 @@ jobs:
run: | run: |
echo "Action with invalid scannerVersion should have failed but succeeded" echo "Action with invalid scannerVersion should have failed but succeeded"
exit 1 exit 1
qa-gate:
name: QA Main - gate
runs-on: ubuntu-latest
needs:
- noInputsTest
- argsInputTest
- argsInputInjectionTest
- backtickCommandInjectionTest
- dollarSymbolCommandInjectionTest
- otherCommandInjectionVariantsTest
- projectBaseDirInputTest
- scannerVersionTest
- scannerBinariesUrlTest
- scannerBinariesUrlCommandInjectionTest
- scannerBinariesUrlCommandInjectionWithSpacesTest
- dontFailGradleTest
- dontFailGradleKotlinTest
- dontFailMavenTest
- runAnalysisTest
- runnerDebugUsedTest
- runAnalysisWithCacheTest
- overrideSonarcloudUrlTest
- scannerBinariesUrlRedirectFollowed
- useSslCertificate
- analysisWithSslCertificate
- updateTruststoreWhenPresent
- scannerVersionValidationTest
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All checks passed."
+16 -2
View File
@@ -200,6 +200,20 @@ This can be useful when the runner executing the action is self-hosted and has r
scannerBinariesUrl: https://my.custom.binaries.url.com/Distribution/sonar-scanner-cli/ scannerBinariesUrl: https://my.custom.binaries.url.com/Distribution/sonar-scanner-cli/
``` ```
#### `scannerBinariesAuthHeader`
If the server specified by `scannerBinariesUrl` requires authentication, you can provide an `Authorization` header value using the `scannerBinariesAuthHeader` option.
The value is passed directly as the `Authorization` HTTP header, so you must include the scheme (e.g. `Bearer`, `Basic`):
```yaml
- uses: SonarSource/sonarqube-scan-action@<action version>
with:
scannerBinariesUrl: https://my.custom.binaries.url.com/Distribution/sonar-scanner-cli/
scannerBinariesAuthHeader: ${{ secrets.BINARIES_AUTH_HEADER }}
```
Store the full header value (e.g. `Bearer mytoken`) in the GitHub secret to avoid exposing credentials.
#### `skipSignatureVerification` #### `skipSignatureVerification`
By default, the action verifies the OpenPGP signature of the SonarScanner CLI binary before executing it. You can disable this verification using the `skipSignatureVerification` option: By default, the action verifies the OpenPGP signature of the SonarScanner CLI binary before executing it. You can disable this verification using the `skipSignatureVerification` option:
@@ -469,11 +483,11 @@ See also [example configurations of C++ projects for SonarQube Server](https://g
When running the action in a self-hosted runner or container, please ensure that the following programs are installed: When running the action in a self-hosted runner or container, please ensure that the following programs are installed:
* **curl** or **wget**
* **unzip**
* **gpg** * **gpg**
* **dirmngr** * **dirmngr**
Note: `gpg` and `dirmngr` are only required for GPG signature verification (enabled by default). They can be omitted when setting `skipSignatureVerification: true`.
### Additional information ### Additional information
The `sonarqube-scan-action/install-build-wrapper` action installs `coreutils` if run on macOS. The `sonarqube-scan-action/install-build-wrapper` action installs `coreutils` if run on macOS.
+7
View File
@@ -28,6 +28,13 @@ inputs:
description: Skip GPG signature verification (not recommended for security) description: Skip GPG signature verification (not recommended for security)
required: false required: false
default: "false" default: "false"
scannerBinariesAuthHeader:
description: >
Authorization header value to use when downloading the SonarScanner CLI binaries
(e.g. 'Bearer mytoken' or 'Basic base64creds'). Use this when scannerBinariesUrl
points to a private server that requires authentication.
required: false
default: ""
runs: runs:
using: node24 using: node24
main: dist/index.js main: dist/index.js
+55 -8
View File
@@ -10,6 +10,7 @@ import { ok } from 'assert';
import 'string_decoder'; import 'string_decoder';
import * as events from 'events'; import * as events from 'events';
import { setTimeout as setTimeout$1 } from 'timers'; import { setTimeout as setTimeout$1 } from 'timers';
import * as fs$2 from 'node:fs/promises';
import * as os$1 from 'node:os'; import * as os$1 from 'node:os';
import * as path$1 from 'node:path'; import * as path$1 from 'node:path';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -3503,6 +3504,13 @@ function downloadToolAttempt(url, dest, auth, headers) {
const http = new HttpClient(userAgent, [], { const http = new HttpClient(userAgent, [], {
allowRetries: false allowRetries: false
}); });
if (auth) {
debug('set auth');
if (headers === undefined) {
headers = {};
}
headers.authorization = auth;
}
const response = yield http.get(url, headers); const response = yield http.get(url, headers);
if (response.message.statusCode !== 200) { if (response.message.statusCode !== 200) {
const err = new HTTPError(response.message.statusCode); const err = new HTTPError(response.message.statusCode);
@@ -3855,6 +3863,19 @@ function getScannerDownloadURL({
const scannerDirName = (version, flavor) => const scannerDirName = (version, flavor) =>
`sonar-scanner-${version}-${flavor}`; `sonar-scanner-${version}-${flavor}`;
/**
* Converts a 4-part version string (e.g. "8.0.1.6346") to a SemVer 2.0 compatible
* string (e.g. "8.0.1-build.6346") for use with GitHub's tool-cache library,
* which requires SemVer-compliant version strings.
*/
function toSemVer(version) {
const parts = version.split(".");
if (parts.length === 4) {
return `${parts[0]}.${parts[1]}.${parts[2]}-build.${parts[3]}`;
}
return version;
}
/* /*
* sonarqube-scan-action * sonarqube-scan-action
* Copyright (C) 2025 SonarSource SA * Copyright (C) 2025 SonarSource SA
@@ -4134,18 +4155,29 @@ function cleanupGpgHome(gpgHome) {
const TOOLNAME = "sonar-scanner-cli"; const TOOLNAME = "sonar-scanner-cli";
async function ensureZipExtension(filePath) {
if (filePath.endsWith(".zip")) {
return filePath;
}
const zipPath = `${filePath}.zip`;
await fs$2.rename(filePath, zipPath);
return zipPath;
}
/** /**
* Download the Sonar Scanner CLI for the current environment and cache it. * Download the Sonar Scanner CLI for the current environment and cache it.
*/ */
async function installSonarScanner({ async function installSonarScanner({
scannerVersion, scannerVersion,
scannerBinariesUrl, scannerBinariesUrl,
scannerBinariesAuthHeader,
skipSignatureVerification = false, skipSignatureVerification = false,
}) { }) {
const flavor = getPlatformFlavor(os$1.platform(), os$1.arch()); const flavor = getPlatformFlavor(os$1.platform(), os$1.arch());
const semVerVersion = toSemVer(scannerVersion);
// Check if tool is already cached // Check if tool is already cached
let toolDir = find(TOOLNAME, scannerVersion, flavor); let toolDir = find(TOOLNAME, semVerVersion, flavor);
if (!toolDir) { if (!toolDir) {
info( info(
@@ -4160,7 +4192,7 @@ async function installSonarScanner({
info(`Downloading from: ${downloadUrl}`); info(`Downloading from: ${downloadUrl}`);
const downloadPath = await downloadTool(downloadUrl); const downloadPath = await downloadTool(downloadUrl, undefined, scannerBinariesAuthHeader);
if (skipSignatureVerification) { if (skipSignatureVerification) {
warning("⚠ Skipping GPG signature verification (not recommended)"); warning("⚠ Skipping GPG signature verification (not recommended)");
@@ -4170,7 +4202,7 @@ async function installSonarScanner({
let signaturePath; let signaturePath;
try { try {
signaturePath = await downloadTool(signatureUrl); signaturePath = await downloadTool(signatureUrl, undefined, scannerBinariesAuthHeader);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to download signature file from ${signatureUrl}: ${error.message}` `Failed to download signature file from ${signatureUrl}: ${error.message}`
@@ -4180,7 +4212,9 @@ async function installSonarScanner({
await verifySignature(downloadPath, signaturePath); await verifySignature(downloadPath, signaturePath);
} }
const extractedPath = await extractZip(downloadPath); // PowerShell 5.1 (used on some Windows agents) requires the .zip extension for Expand-Archive
const extractInput = await ensureZipExtension(downloadPath);
const extractedPath = await extractZip(extractInput);
// Find the actual scanner directory inside the extracted folder // Find the actual scanner directory inside the extracted folder
const scannerPath = path$1.join( const scannerPath = path$1.join(
@@ -4188,7 +4222,7 @@ async function installSonarScanner({
scannerDirName(scannerVersion, flavor) scannerDirName(scannerVersion, flavor)
); );
toolDir = await cacheDir(scannerPath, TOOLNAME, scannerVersion, flavor); toolDir = await cacheDir(scannerPath, TOOLNAME, semVerVersion, flavor);
info(`Sonar Scanner CLI cached to: ${toolDir}`); info(`Sonar Scanner CLI cached to: ${toolDir}`);
} else { } else {
@@ -4489,10 +4523,14 @@ function getInputs() {
const args = getInput("args"); const args = getInput("args");
const projectBaseDir = getInput("projectBaseDir"); const projectBaseDir = getInput("projectBaseDir");
const scannerBinariesUrl = getInput("scannerBinariesUrl"); const scannerBinariesUrl = getInput("scannerBinariesUrl");
const scannerBinariesAuthHeader = getInput("scannerBinariesAuthHeader") || undefined;
if (scannerBinariesAuthHeader) {
setSecret(scannerBinariesAuthHeader);
}
const scannerVersion = getInput("scannerVersion"); const scannerVersion = getInput("scannerVersion");
const skipSignatureVerification = getBooleanInput("skipSignatureVerification"); const skipSignatureVerification = getBooleanInput("skipSignatureVerification");
return { args, projectBaseDir, scannerBinariesUrl, scannerVersion, skipSignatureVerification }; return { args, projectBaseDir, scannerBinariesUrl, scannerBinariesAuthHeader, scannerVersion, skipSignatureVerification };
} }
/** /**
@@ -4528,16 +4566,25 @@ function runSanityChecks(inputs) {
async function run() { async function run() {
try { try {
const { args, projectBaseDir, scannerVersion, scannerBinariesUrl, skipSignatureVerification } = const { args, projectBaseDir, scannerVersion, scannerBinariesUrl, scannerBinariesAuthHeader, skipSignatureVerification } =
getInputs(); getInputs();
const runnerEnv = getEnvVariables(); const runnerEnv = getEnvVariables();
const { sonarToken } = runnerEnv; const { sonarToken, sonarcloudUrl } = runnerEnv;
if (sonarcloudUrl) {
warning(
"The SONARCLOUD_URL environment variable is deprecated and will be removed in a future version. " +
"Regular users should not set it; use SONAR_REGION=us for the US region. " +
"For advanced needs, pass -Dsonar.scanner.sonarcloudUrl and -Dsonar.scanner.apiBaseUrl via the args input."
);
}
runSanityChecks({ projectBaseDir, scannerVersion, sonarToken }); runSanityChecks({ projectBaseDir, scannerVersion, sonarToken });
const scannerDir = await installSonarScanner({ const scannerDir = await installSonarScanner({
scannerVersion, scannerVersion,
scannerBinariesUrl, scannerBinariesUrl,
scannerBinariesAuthHeader,
skipSignatureVerification, skipSignatureVerification,
}); });
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24"
+153
View File
@@ -0,0 +1,153 @@
/*
* sonarqube-scan-action
* Copyright (C) 2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
function mockDependencies(t, { getInputFn, setSecretFn }) {
t.mock.module("@actions/core", {
namedExports: {
getInput: getInputFn,
getBooleanInput: mock.fn(() => false),
setSecret: setSecretFn,
setFailed: mock.fn(),
info: mock.fn(),
warning: mock.fn(),
},
});
t.mock.module("../install-sonar-scanner.js", {
namedExports: { installSonarScanner: mock.fn(async () => "/scanner") },
});
t.mock.module("../run-sonar-scanner.js", {
namedExports: { runSonarScanner: mock.fn(async () => {}) },
});
t.mock.module("../sanity-checks.js", {
namedExports: {
validateScannerVersion: mock.fn(),
checkSonarToken: mock.fn(),
checkMavenProject: mock.fn(),
checkGradleProject: mock.fn(),
},
});
}
describe("SONARCLOUD_URL deprecation", () => {
it("should warn when SONARCLOUD_URL is set", async (t) => {
const warningFn = mock.fn();
const getInputFn = mock.fn(() => "");
t.mock.module("@actions/core", {
namedExports: {
getInput: getInputFn,
getBooleanInput: mock.fn(() => false),
setSecret: mock.fn(),
setFailed: mock.fn(),
info: mock.fn(),
warning: warningFn,
},
});
t.mock.module("../install-sonar-scanner.js", {
namedExports: { installSonarScanner: mock.fn(async () => "/scanner") },
});
t.mock.module("../run-sonar-scanner.js", {
namedExports: { runSonarScanner: mock.fn(async () => {}) },
});
t.mock.module("../sanity-checks.js", {
namedExports: {
validateScannerVersion: mock.fn(),
checkSonarToken: mock.fn(),
checkMavenProject: mock.fn(),
checkGradleProject: mock.fn(),
},
});
process.env.SONARCLOUD_URL = "mirror.sonarcloud.io";
t.after(() => delete process.env.SONARCLOUD_URL);
await import("../index.js?test=deprecation-warning");
assert.equal(warningFn.mock.calls.length, 1);
assert.match(
warningFn.mock.calls[0].arguments[0],
/SONARCLOUD_URL.*deprecated/
);
});
it("should not warn when SONARCLOUD_URL is not set", async (t) => {
const warningFn = mock.fn();
const getInputFn = mock.fn(() => "");
t.mock.module("@actions/core", {
namedExports: {
getInput: getInputFn,
getBooleanInput: mock.fn(() => false),
setSecret: mock.fn(),
setFailed: mock.fn(),
info: mock.fn(),
warning: warningFn,
},
});
t.mock.module("../install-sonar-scanner.js", {
namedExports: { installSonarScanner: mock.fn(async () => "/scanner") },
});
t.mock.module("../run-sonar-scanner.js", {
namedExports: { runSonarScanner: mock.fn(async () => {}) },
});
t.mock.module("../sanity-checks.js", {
namedExports: {
validateScannerVersion: mock.fn(),
checkSonarToken: mock.fn(),
checkMavenProject: mock.fn(),
checkGradleProject: mock.fn(),
},
});
delete process.env.SONARCLOUD_URL;
await import("../index.js?test=no-deprecation-warning");
assert.equal(warningFn.mock.calls.length, 0);
});
});
describe("getInputs", () => {
it("should mask scannerBinariesAuthHeader using setSecret when provided", async (t) => {
const setSecretFn = mock.fn();
const getInputFn = mock.fn((name) => name === "scannerBinariesAuthHeader" ? "Bearer mytoken" : "");
mockDependencies(t, { getInputFn, setSecretFn });
await import("../index.js?test=set-secret");
assert.equal(setSecretFn.mock.calls.length, 1);
assert.equal(setSecretFn.mock.calls[0].arguments[0], "Bearer mytoken");
});
it("should not call setSecret when scannerBinariesAuthHeader is not provided", async (t) => {
const setSecretFn = mock.fn();
const getInputFn = mock.fn(() => "");
mockDependencies(t, { getInputFn, setSecretFn });
await import("../index.js?test=no-set-secret");
assert.equal(setSecretFn.mock.calls.length, 0);
});
});
@@ -0,0 +1,381 @@
/*
* sonarqube-scan-action
* Copyright (C) 2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import nodeFsPromises from "node:fs/promises";
const SCANNER_VERSION = "6.2.0.4584";
const SCANNER_SEMVER_VERSION = "6.2.0-build.4584";
const BINARIES_URL = "https://my.artifactory.example.com/sonar-scanner-cli";
const BINARY_DOWNLOAD_URL = `${BINARIES_URL}/sonar-scanner-cli-${SCANNER_VERSION}-linux-x64.zip`;
function mockUtils(t) {
t.mock.module("../utils.js", {
namedExports: {
getPlatformFlavor: mock.fn(() => "linux-x64"),
getScannerDownloadURL: mock.fn(() => BINARY_DOWNLOAD_URL),
scannerDirName: mock.fn(() => `sonar-scanner-${SCANNER_VERSION}-linux-x64`),
toSemVer: mock.fn(() => SCANNER_SEMVER_VERSION),
},
});
}
function mockFsPromises(t) {
t.mock.module("node:fs/promises", {
namedExports: {
...nodeFsPromises,
rename: mock.fn(async () => {}),
},
});
}
describe("installSonarScanner", () => {
it("should forward scannerBinariesAuthHeader to both binary and signature downloads", async (t) => {
const downloadCalls = [];
const downloadToolFn = mock.fn(async (url, dest, auth) => {
downloadCalls.push({ url, auth });
return `/tmp/downloaded-${downloadCalls.length}`;
});
mockUtils(t);
mockFsPromises(t);
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => null),
downloadTool: downloadToolFn,
extractZip: mock.fn(async () => "/tmp/extracted"),
cacheDir: mock.fn(async () => "/tmp/cached"),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
t.mock.module("../gpg-verification.js", {
namedExports: {
verifySignature: mock.fn(async () => {}),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=auth-header`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
scannerBinariesAuthHeader: "Bearer mytoken",
});
assert.equal(downloadCalls.length, 2, "Should download binary and signature");
assert.equal(downloadCalls[0].auth, "Bearer mytoken", "Binary download should use auth header");
assert.equal(downloadCalls[1].auth, "Bearer mytoken", "Signature download should use auth header");
assert.ok(downloadCalls[1].url.endsWith(".asc"), "Second download should be the signature");
});
it("should not set auth header when scannerBinariesAuthHeader is not provided", async (t) => {
const downloadCalls = [];
const downloadToolFn = mock.fn(async (url, dest, auth) => {
downloadCalls.push({ url, auth });
return `/tmp/downloaded-${downloadCalls.length}`;
});
mockUtils(t);
mockFsPromises(t);
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => null),
downloadTool: downloadToolFn,
extractZip: mock.fn(async () => "/tmp/extracted"),
cacheDir: mock.fn(async () => "/tmp/cached"),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
t.mock.module("../gpg-verification.js", {
namedExports: {
verifySignature: mock.fn(async () => {}),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=no-auth-header`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
});
assert.equal(downloadCalls.length, 2);
assert.equal(downloadCalls[0].auth, undefined, "Binary download should have no auth header");
assert.equal(downloadCalls[1].auth, undefined, "Signature download should have no auth header");
});
it("should skip signature download when skipSignatureVerification is true", async (t) => {
const downloadCalls = [];
const downloadToolFn = mock.fn(async (url, dest, auth) => {
downloadCalls.push({ url, auth });
return `/tmp/downloaded-${downloadCalls.length}`;
});
mockUtils(t);
mockFsPromises(t);
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => null),
downloadTool: downloadToolFn,
extractZip: mock.fn(async () => "/tmp/extracted"),
cacheDir: mock.fn(async () => "/tmp/cached"),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=skip-sig`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
scannerBinariesAuthHeader: "Bearer mytoken",
skipSignatureVerification: true,
});
assert.equal(downloadCalls.length, 1, "Should only download binary, not signature");
assert.equal(downloadCalls[0].auth, "Bearer mytoken");
});
it("should use semver-compatible version for tool-cache find and cacheDir", async (t) => {
const findFn = mock.fn(() => null);
const cacheDirFn = mock.fn(async () => "/tmp/cached");
mockUtils(t);
mockFsPromises(t);
t.mock.module("@actions/tool-cache", {
namedExports: {
find: findFn,
downloadTool: mock.fn(async () => "/tmp/downloaded"),
extractZip: mock.fn(async () => "/tmp/extracted"),
cacheDir: cacheDirFn,
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
t.mock.module("../gpg-verification.js", {
namedExports: {
verifySignature: mock.fn(async () => {}),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=semver-version`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
});
assert.equal(findFn.mock.calls[0].arguments[1], SCANNER_SEMVER_VERSION,
"tc.find should be called with semver-compatible version");
assert.equal(cacheDirFn.mock.calls[0].arguments[2], SCANNER_SEMVER_VERSION,
"tc.cacheDir should be called with semver-compatible version");
});
it("should rename downloaded file to add .zip extension before extraction", async (t) => {
const renameCalls = [];
const extractZipCalls = [];
mockUtils(t);
t.mock.module("node:fs/promises", {
namedExports: {
...nodeFsPromises,
rename: mock.fn(async (src, dest) => {
renameCalls.push({ src, dest });
}),
},
});
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => null),
downloadTool: mock.fn(async () => "/tmp/downloaded-file"),
extractZip: mock.fn(async (p) => {
extractZipCalls.push(p);
return "/tmp/extracted";
}),
cacheDir: mock.fn(async () => "/tmp/cached"),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
t.mock.module("../gpg-verification.js", {
namedExports: {
verifySignature: mock.fn(async () => {}),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=rename-zip`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
skipSignatureVerification: true,
});
assert.equal(renameCalls.length, 1, "Should rename downloaded file");
assert.equal(renameCalls[0].src, "/tmp/downloaded-file");
assert.equal(renameCalls[0].dest, "/tmp/downloaded-file.zip");
assert.equal(extractZipCalls.length, 1, "Should call extractZip once");
assert.equal(extractZipCalls[0], "/tmp/downloaded-file.zip", "Should extract the renamed file");
});
it("should not rename downloaded file when it already has .zip extension", async (t) => {
const renameCalls = [];
const extractZipCalls = [];
mockUtils(t);
t.mock.module("node:fs/promises", {
namedExports: {
...nodeFsPromises,
rename: mock.fn(async (src, dest) => {
renameCalls.push({ src, dest });
}),
},
});
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => null),
downloadTool: mock.fn(async () => "/tmp/downloaded-file.zip"),
extractZip: mock.fn(async (p) => {
extractZipCalls.push(p);
return "/tmp/extracted";
}),
cacheDir: mock.fn(async () => "/tmp/cached"),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
t.mock.module("../gpg-verification.js", {
namedExports: {
verifySignature: mock.fn(async () => {}),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=no-rename-zip`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
skipSignatureVerification: true,
});
assert.equal(renameCalls.length, 0, "Should not rename when already .zip");
assert.equal(extractZipCalls.length, 1, "Should call extractZip once");
assert.equal(extractZipCalls[0], "/tmp/downloaded-file.zip", "Should extract original file");
});
it("should use cached tool when available and skip download", async (t) => {
const downloadToolFn = mock.fn();
mockUtils(t);
t.mock.module("@actions/tool-cache", {
namedExports: {
find: mock.fn(() => "/tmp/cached-tool"),
downloadTool: downloadToolFn,
extractZip: mock.fn(),
cacheDir: mock.fn(),
},
});
t.mock.module("@actions/core", {
namedExports: {
info: mock.fn(),
warning: mock.fn(),
addPath: mock.fn(),
},
});
const { installSonarScanner } = await import(
`../install-sonar-scanner.js?test=cached`
);
await installSonarScanner({
scannerVersion: SCANNER_VERSION,
scannerBinariesUrl: BINARIES_URL,
});
assert.equal(downloadToolFn.mock.calls.length, 0, "Should not download when cached");
});
});
+20
View File
@@ -22,6 +22,7 @@ import {
getPlatformFlavor, getPlatformFlavor,
getScannerDownloadURL, getScannerDownloadURL,
scannerDirName, scannerDirName,
toSemVer,
} from "../utils.js"; } from "../utils.js";
describe("getPlatformFlavor", () => { describe("getPlatformFlavor", () => {
@@ -97,3 +98,22 @@ describe("scannerDirName", () => {
); );
}); });
}); });
describe("toSemVer", () => {
it("converts 4-part version to semver pre-release format", () => {
assert.equal(toSemVer("8.0.1.6346"), "8.0.1-build.6346");
});
it("leaves 3-part semver version unchanged", () => {
assert.equal(toSemVer("8.0.1"), "8.0.1");
});
it("leaves version with pre-release identifier unchanged", () => {
assert.equal(toSemVer("7.2.0-SNAPSHOT"), "7.2.0-SNAPSHOT");
});
it("converts different 4-part versions correctly", () => {
assert.equal(toSemVer("6.2.0.4584"), "6.2.0-build.4584");
assert.equal(toSemVer("8.1.0.6389"), "8.1.0-build.6389");
});
});
+19 -6
View File
@@ -17,14 +17,14 @@
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. // Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import * as core from "@actions/core"; import * as core from "@actions/core";
import { installSonarScanner } from "./install-sonar-scanner"; import { installSonarScanner } from "./install-sonar-scanner.js";
import { runSonarScanner } from "./run-sonar-scanner"; import { runSonarScanner } from "./run-sonar-scanner.js";
import { import {
checkGradleProject, checkGradleProject,
checkMavenProject, checkMavenProject,
checkSonarToken, checkSonarToken,
validateScannerVersion, validateScannerVersion,
} from "./sanity-checks"; } from "./sanity-checks.js";
/** /**
* Inputs are defined in action.yml * Inputs are defined in action.yml
@@ -33,10 +33,14 @@ function getInputs() {
const args = core.getInput("args"); const args = core.getInput("args");
const projectBaseDir = core.getInput("projectBaseDir"); const projectBaseDir = core.getInput("projectBaseDir");
const scannerBinariesUrl = core.getInput("scannerBinariesUrl"); const scannerBinariesUrl = core.getInput("scannerBinariesUrl");
const scannerBinariesAuthHeader = core.getInput("scannerBinariesAuthHeader") || undefined;
if (scannerBinariesAuthHeader) {
core.setSecret(scannerBinariesAuthHeader);
}
const scannerVersion = core.getInput("scannerVersion"); const scannerVersion = core.getInput("scannerVersion");
const skipSignatureVerification = core.getBooleanInput("skipSignatureVerification"); const skipSignatureVerification = core.getBooleanInput("skipSignatureVerification");
return { args, projectBaseDir, scannerBinariesUrl, scannerVersion, skipSignatureVerification }; return { args, projectBaseDir, scannerBinariesUrl, scannerBinariesAuthHeader, scannerVersion, skipSignatureVerification };
} }
/** /**
@@ -72,16 +76,25 @@ function runSanityChecks(inputs) {
async function run() { async function run() {
try { try {
const { args, projectBaseDir, scannerVersion, scannerBinariesUrl, skipSignatureVerification } = const { args, projectBaseDir, scannerVersion, scannerBinariesUrl, scannerBinariesAuthHeader, skipSignatureVerification } =
getInputs(); getInputs();
const runnerEnv = getEnvVariables(); const runnerEnv = getEnvVariables();
const { sonarToken } = runnerEnv; const { sonarToken, sonarcloudUrl } = runnerEnv;
if (sonarcloudUrl) {
core.warning(
"The SONARCLOUD_URL environment variable is deprecated and will be removed in a future version. " +
"Regular users should not set it; use SONAR_REGION=us for the US region. " +
"For advanced needs, pass -Dsonar.scanner.sonarcloudUrl and -Dsonar.scanner.apiBaseUrl via the args input."
);
}
runSanityChecks({ projectBaseDir, scannerVersion, sonarToken }); runSanityChecks({ projectBaseDir, scannerVersion, sonarToken });
const scannerDir = await installSonarScanner({ const scannerDir = await installSonarScanner({
scannerVersion, scannerVersion,
scannerBinariesUrl, scannerBinariesUrl,
scannerBinariesAuthHeader,
skipSignatureVerification, skipSignatureVerification,
}); });
+22 -7
View File
@@ -18,29 +18,42 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import * as tc from "@actions/tool-cache"; import * as tc from "@actions/tool-cache";
import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { import {
getPlatformFlavor, getPlatformFlavor,
getScannerDownloadURL, getScannerDownloadURL,
scannerDirName, scannerDirName,
} from "./utils"; toSemVer,
import { verifySignature } from "./gpg-verification"; } from "./utils.js";
import { verifySignature } from "./gpg-verification.js";
const TOOLNAME = "sonar-scanner-cli"; const TOOLNAME = "sonar-scanner-cli";
async function ensureZipExtension(filePath) {
if (filePath.endsWith(".zip")) {
return filePath;
}
const zipPath = `${filePath}.zip`;
await fs.rename(filePath, zipPath);
return zipPath;
}
/** /**
* Download the Sonar Scanner CLI for the current environment and cache it. * Download the Sonar Scanner CLI for the current environment and cache it.
*/ */
export async function installSonarScanner({ export async function installSonarScanner({
scannerVersion, scannerVersion,
scannerBinariesUrl, scannerBinariesUrl,
scannerBinariesAuthHeader,
skipSignatureVerification = false, skipSignatureVerification = false,
}) { }) {
const flavor = getPlatformFlavor(os.platform(), os.arch()); const flavor = getPlatformFlavor(os.platform(), os.arch());
const semVerVersion = toSemVer(scannerVersion);
// Check if tool is already cached // Check if tool is already cached
let toolDir = tc.find(TOOLNAME, scannerVersion, flavor); let toolDir = tc.find(TOOLNAME, semVerVersion, flavor);
if (!toolDir) { if (!toolDir) {
core.info( core.info(
@@ -55,7 +68,7 @@ export async function installSonarScanner({
core.info(`Downloading from: ${downloadUrl}`); core.info(`Downloading from: ${downloadUrl}`);
const downloadPath = await tc.downloadTool(downloadUrl); const downloadPath = await tc.downloadTool(downloadUrl, undefined, scannerBinariesAuthHeader);
if (skipSignatureVerification) { if (skipSignatureVerification) {
core.warning("⚠ Skipping GPG signature verification (not recommended)"); core.warning("⚠ Skipping GPG signature verification (not recommended)");
@@ -65,7 +78,7 @@ export async function installSonarScanner({
let signaturePath; let signaturePath;
try { try {
signaturePath = await tc.downloadTool(signatureUrl); signaturePath = await tc.downloadTool(signatureUrl, undefined, scannerBinariesAuthHeader);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to download signature file from ${signatureUrl}: ${error.message}` `Failed to download signature file from ${signatureUrl}: ${error.message}`
@@ -75,7 +88,9 @@ export async function installSonarScanner({
await verifySignature(downloadPath, signaturePath); await verifySignature(downloadPath, signaturePath);
} }
const extractedPath = await tc.extractZip(downloadPath); // PowerShell 5.1 (used on some Windows agents) requires the .zip extension for Expand-Archive
const extractInput = await ensureZipExtension(downloadPath);
const extractedPath = await tc.extractZip(extractInput);
// Find the actual scanner directory inside the extracted folder // Find the actual scanner directory inside the extracted folder
const scannerPath = path.join( const scannerPath = path.join(
@@ -83,7 +98,7 @@ export async function installSonarScanner({
scannerDirName(scannerVersion, flavor) scannerDirName(scannerVersion, flavor)
); );
toolDir = await tc.cacheDir(scannerPath, TOOLNAME, scannerVersion, flavor); toolDir = await tc.cacheDir(scannerPath, TOOLNAME, semVerVersion, flavor);
core.info(`Sonar Scanner CLI cached to: ${toolDir}`); core.info(`Sonar Scanner CLI cached to: ${toolDir}`);
} else { } else {
+13
View File
@@ -51,3 +51,16 @@ export function getScannerDownloadURL({
export const scannerDirName = (version, flavor) => export const scannerDirName = (version, flavor) =>
`sonar-scanner-${version}-${flavor}`; `sonar-scanner-${version}-${flavor}`;
/**
* Converts a 4-part version string (e.g. "8.0.1.6346") to a SemVer 2.0 compatible
* string (e.g. "8.0.1-build.6346") for use with GitHub's tool-cache library,
* which requires SemVer-compliant version strings.
*/
export function toSemVer(version) {
const parts = version.split(".");
if (parts.length === 4) {
return `${parts[0]}.${parts[1]}.${parts[2]}-build.${parts[3]}`;
}
return version;
}