Compare commits

...

5 Commits

Author SHA1 Message Date
Julien HENRY b24f324433 Use mise 2026-06-04 16:43:59 +02:00
Julien HENRY ec7d7e54b2 SQSCANGHA-127 Rename downloaded file to .zip before extraction on Windows
PowerShell 5.1, used on some Windows GitHub Actions runners, requires
Expand-Archive to receive a file with a .zip extension. The @actions/tool-cache
downloadTool function saves to a temp path without any extension, causing
extraction to fail on PS 5.1. Rename the file to add .zip before calling
extractZip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:43:18 +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
12 changed files with 654 additions and 20 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."
+39 -2
View File
@@ -451,7 +451,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 +461,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
@@ -827,3 +827,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
- scannerBinariesUrlIsEscapedWithWget
- scannerBinariesUrlIsEscapedWithCurl
- dontFailGradleTest
- dontFailGradleKotlinTest
- dontFailMavenTest
- runAnalysisTest
- runnerDebugUsedTest
- runAnalysisWithCacheTest
- overrideSonarcloudUrlTest
- curlPerformsRedirect
- 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."
+14
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:
+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
+39 -6
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);
@@ -4134,12 +4142,22 @@ 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());
@@ -4160,7 +4178,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 +4188,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 +4198,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(
@@ -4489,10 +4509,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 +4552,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,334 @@
/*
* 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 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`),
},
});
}
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 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");
});
});
+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,
}); });
+18 -5
View File
@@ -18,23 +18,34 @@
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"; } from "./utils.js";
import { verifySignature } from "./gpg-verification"; 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());
@@ -55,7 +66,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 +76,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 +86,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(