Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/actions/qtmesh/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ inputs:
description: 'Docker image tag to use'
required: false
default: 'latest'
qtmesh-token:
description: 'Optional. Sets QTMESH_TOKEN in the container (same as CLI --token). Use qtmesh-no-upload / qtmesh-strict-upload or options for other scan flags.'
required: false
qtmesh-api-base:
description: 'Optional. Sets QTMESH_API_BASE in the container.'
required: false
qtmesh-no-upload:
description: 'When true and command=scan, pass --no-upload.'
required: false
default: 'false'
qtmesh-strict-upload:
description: 'When true and command=scan, pass --strict-upload.'
required: false
default: 'false'

outputs:
result:
Expand All @@ -34,6 +48,10 @@ runs:
INPUT_FILE: ${{ inputs.input-file }}
INPUT_OUTPUT_FILE: ${{ inputs.output-file }}
INPUT_OPTIONS: ${{ inputs.options }}
INPUT_QTMESH_TOKEN: ${{ inputs.qtmesh-token }}
INPUT_QTMESH_API_BASE: ${{ inputs.qtmesh-api-base }}
INPUT_QTMESH_NO_UPLOAD: ${{ inputs.qtmesh-no-upload }}
INPUT_QTMESH_STRICT_UPLOAD: ${{ inputs.qtmesh-strict-upload }}
run: |
# Build command args safely via arrays to prevent injection
cmd=("$INPUT_COMMAND" "/workspace/$INPUT_FILE")
Expand All @@ -44,10 +62,27 @@ runs:
read -r -a opts <<< "$INPUT_OPTIONS"
cmd+=("${opts[@]}")
fi
if [ "$INPUT_COMMAND" = "scan" ]; then
if [ "${INPUT_QTMESH_NO_UPLOAD:-false}" = "true" ]; then
cmd+=("--no-upload")
fi
if [ "${INPUT_QTMESH_STRICT_UPLOAD:-false}" = "true" ]; then
cmd+=("--strict-upload")
fi
fi

docker_env=()
if [ -n "${INPUT_QTMESH_TOKEN:-}" ]; then
docker_env+=(--env "QTMESH_TOKEN=${INPUT_QTMESH_TOKEN}")
fi
if [ -n "${INPUT_QTMESH_API_BASE:-}" ]; then
docker_env+=(--env "QTMESH_API_BASE=${INPUT_QTMESH_API_BASE}")
fi

OUTPUT=$(docker run --rm \
--user "$(id -u):$(id -g)" \
-v "${{ github.workspace }}:/workspace" \
"${docker_env[@]}" \
"ghcr.io/fernandotonon/qtmesh:${{ inputs.image-tag }}" \
"${cmd[@]}")

Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
cmake_policy(SET CMP0005 NEW)
cmake_policy(SET CMP0048 NEW) # manages project version

project(QtMeshEditor VERSION 2.25.1 LANGUAGES C CXX)
project(QtMeshEditor VERSION 2.26.0 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
35 changes: 35 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ inputs:
badge-base-url:
description: 'Public base URL hosting badge JSON files (used to expose ready-to-use badge URLs in outputs).'
required: false
qtmesh-token:
description: 'Optional. Sets QTMESH_TOKEN in the container for QtMesh Cloud (same effect as CLI --token). Combine with qtmesh-no-upload / qtmesh-strict-upload or pass extra flags via options.'
required: false
qtmesh-api-base:
description: 'Optional. Sets QTMESH_API_BASE (override API host, e.g. for self-hosted).'
required: false
qtmesh-no-upload:
description: 'When true and command=scan, append --no-upload (skip cloud POST even if QTMESH_TOKEN is set).'
required: false
default: 'false'
qtmesh-strict-upload:
description: 'When true and command=scan, append --strict-upload (fail the job if cloud upload fails).'
required: false
default: 'false'

outputs:
result:
Expand Down Expand Up @@ -85,6 +99,10 @@ runs:
INPUT_BADGE_OUTPUT_DIR: ${{ inputs.badge-output-dir }}
INPUT_BADGE_LABEL_PREFIX: ${{ inputs.badge-label-prefix }}
INPUT_BADGE_BASE_URL: ${{ inputs.badge-base-url }}
INPUT_QTMESH_TOKEN: ${{ inputs.qtmesh-token }}
INPUT_QTMESH_API_BASE: ${{ inputs.qtmesh-api-base }}
INPUT_QTMESH_NO_UPLOAD: ${{ inputs.qtmesh-no-upload }}
INPUT_QTMESH_STRICT_UPLOAD: ${{ inputs.qtmesh-strict-upload }}
run: |
set -o pipefail

Expand Down Expand Up @@ -122,15 +140,32 @@ runs:
read -r -a opts <<< "$INPUT_OPTIONS"
cmd+=("${opts[@]}")
fi
if [ "$INPUT_COMMAND" = "scan" ]; then
if [ "${INPUT_QTMESH_NO_UPLOAD:-false}" = "true" ]; then
cmd+=("--no-upload")
fi
if [ "${INPUT_QTMESH_STRICT_UPLOAD:-false}" = "true" ]; then
cmd+=("--strict-upload")
fi
fi

tmp_stdout=$(mktemp)
tmp_stderr=$(mktemp)
trap 'rm -f "$tmp_stdout" "$tmp_stderr"' EXIT

docker_env=()
if [ -n "${INPUT_QTMESH_TOKEN:-}" ]; then
docker_env+=(--env "QTMESH_TOKEN=${INPUT_QTMESH_TOKEN}")
fi
if [ -n "${INPUT_QTMESH_API_BASE:-}" ]; then
docker_env+=(--env "QTMESH_API_BASE=${INPUT_QTMESH_API_BASE}")
fi

set +e
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "${{ github.workspace }}:/workspace" \
"${docker_env[@]}" \
"ghcr.io/fernandotonon/qtmesh:${{ inputs.image-tag }}" \
"${cmd[@]}" >"$tmp_stdout" 2>"$tmp_stderr"
EXIT_CODE=$?
Expand Down
4 changes: 4 additions & 0 deletions qtmesh.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
# The scan command picks up qtmesh.yml (or qtmesh.yaml / qtmesh.json)
# automatically from the current directory. Override with --config <path>.
#
# QtMesh Cloud: with QTMESH_TOKEN, QTMESH_CLOUD_TOKEN, or --token <ingest>, and no local
# qtmesh.yml|yaml|json, `qtmesh scan` loads remote rules from the API and uploads the scan
# JSON after each run unless --no-upload is set (see CLI --help).
#
# Run:
# qtmesh scan ./assets
# qtmesh scan ./assets --json
Expand Down
149 changes: 132 additions & 17 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "SentryReporter.h"
#include "ScanConfig.h"
#include "ScanEngine.h"
#include "QtMeshCloudClient.h"
#include <QApplication>
#include <QWidget>
#include <QDir>
Expand Down Expand Up @@ -156,6 +157,10 @@
if (result.skipped > 0)
s << " " << skippedIcon << " Skipped: " << result.skipped << "\n";
s << " " << timeIcon << " Time: " << QString::number(result.elapsedMs / 1000.0, 'f', 1) << "s\n";
QString utcStart, utcEnd;

Check warning on line 160 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define each identifier in a dedicated statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2Oc0d3sht6J4U0flqb&open=AZ2Oc0d3sht6J4U0flqb&pullRequest=287
ScanEngine::scanReportUtcTimes(result, &utcStart, &utcEnd);
s << " UTC start: " << utcStart << "\n";
s << " UTC end: " << utcEnd << "\n";
return out;
}

Expand All @@ -165,6 +170,21 @@
return s;
}

/// Ingest token: `--token` overrides `QTMESH_TOKEN`, then `QTMESH_CLOUD_TOKEN`.
static QString resolveIngestToken(const QString& flagToken)
{
const QString trimmed = flagToken.trimmed();
if (!trimmed.isEmpty())

Check warning on line 177 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "trimmed" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2NsoTJrpw_FOb5OlIK&open=AZ2NsoTJrpw_FOb5OlIK&pullRequest=287
return trimmed;
const QByteArray a = qgetenv("QTMESH_TOKEN");
if (!a.isEmpty())

Check warning on line 180 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "a" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2NsoTKrpw_FOb5OlIL&open=AZ2NsoTKrpw_FOb5OlIL&pullRequest=287
return QString::fromUtf8(a);
const QByteArray b = qgetenv("QTMESH_CLOUD_TOKEN");
if (!b.isEmpty())

Check warning on line 183 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "b" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2NsoTKrpw_FOb5OlIM&open=AZ2NsoTKrpw_FOb5OlIM&pullRequest=287
return QString::fromUtf8(b);
return {};
}

static bool s_verbose = false;
static bool s_noTelemetry = false;

Expand Down Expand Up @@ -262,6 +282,14 @@
" --require-animation-names <list> Required animation names/patterns CSV\n"
" --require-bone-names <list> Required bone names/patterns CSV\n"
" --fail-on <level> Exit 1 threshold: info, warning, error, never\n"
" --token <token> Ingest token (overrides QTMESH_TOKEN / QTMESH_CLOUD_TOKEN)\n"
" --no-upload Skip POSTing scan JSON to QtMesh Cloud when a token is set\n"
" --strict-upload Exit 1 if cloud upload fails (default: warn only)\n"
"\n"
" Cloud rules: if no --config and no local qtmesh.yml|yaml|json, QTMESH_TOKEN loads\n"
" remote rules from the API; otherwise built-in defaults apply if the API is unreachable.\n"
" --config or a local file skips fetching remote rules; scan JSON still uploads when a\n"
" token is set (unless --no-upload). Override API base with QTMESH_API_BASE.\n"
"\n"
"Fix flags:\n"
" --remove-degenerates Remove degenerate triangles\n"
Expand Down Expand Up @@ -1750,6 +1778,9 @@
// Parse: scan [path] [options]
QString scanRoot;
QString configPath;
QString tokenArg;
bool strictUpload = false;
bool noUpload = false;
bool jsonOutput = false;
QString reportPath;
QString sarifPath;
Expand Down Expand Up @@ -1844,6 +1875,8 @@
if (arg == "--json") { jsonOutput = true; continue; }
if (arg == "--fix") { fix = true; continue; }
if (arg == "--dry-run") { dryRun = true; continue; }
if (arg == "--strict-upload") { strictUpload = true; continue; }
if (arg == "--no-upload") { noUpload = true; continue; }
QString value;
ParseValueResult parseResult = parseValueArg(arg, "--config", i, value);
if (parseResult == ParseValueResult::Error) return 2;
Expand All @@ -1863,6 +1896,9 @@
parseResult = parseValueArg(arg, "--fail-on", i, value);
if (parseResult == ParseValueResult::Error) return 2;
if (parseResult == ParseValueResult::Matched) { failOn = value; continue; }
parseResult = parseValueArg(arg, "--token", i, value);
if (parseResult == ParseValueResult::Error) return 2;
if (parseResult == ParseValueResult::Matched) { tokenArg = value; continue; }
parseResult = parseValueArg(arg, "--allowed-formats", i, value);
if (parseResult == ParseValueResult::Error) return 2;
if (parseResult == ParseValueResult::Matched) {
Expand Down Expand Up @@ -1986,22 +2022,59 @@
if (!arg.startsWith("-") && scanRoot.isEmpty()) { scanRoot = arg; continue; }
}

// Load config: explicit file → auto-detect → defaults
// Load config (precedence):
// 1) --config path (never fetch remote rules)
// 2) Else local qtmesh.yml | yaml | json in cwd (never fetch remote rules)
// 3) Else if ingest token set → GET /v1/ingest/rules, or defaults if API fails
// 4) Else built-in defaults
ScanConfig config;
if (!configPath.isEmpty()) {
if (!QFileInfo::exists(configPath)) {
err() << "Error: Config file not found: " << configPath << Qt::endl;
return 2;
}
config = ScanConfig::loadFromFile(configPath);
} else if (QFileInfo::exists("qtmesh.yml")) {
config = ScanConfig::loadFromFile("qtmesh.yml");
} else if (QFileInfo::exists("qtmesh.yaml")) {
config = ScanConfig::loadFromFile("qtmesh.yaml");
} else if (QFileInfo::exists("qtmesh.json")) {
config = ScanConfig::loadFromFile("qtmesh.json");
if (!resolveIngestToken(tokenArg).isEmpty()) {
err() << "Note: Using --config file; remote cloud rules were not fetched."
<< " Scan JSON is still uploaded when an ingest token is set (unless --no-upload)."
<< Qt::endl;
}
} else {
config = ScanConfig::defaults();
QString localAutoPath;
if (QFileInfo::exists(QStringLiteral("qtmesh.yml")))
localAutoPath = QStringLiteral("qtmesh.yml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.yaml")))
localAutoPath = QStringLiteral("qtmesh.yaml");
else if (QFileInfo::exists(QStringLiteral("qtmesh.json")))
localAutoPath = QStringLiteral("qtmesh.json");

if (!localAutoPath.isEmpty()) {
config = ScanConfig::loadFromFile(localAutoPath);
err() << "Note: Using local " << localAutoPath
<< " — QtMesh Cloud remote rules are not used for validation." << Qt::endl;
} else {
const QString ingestForRules = resolveIngestToken(tokenArg);
if (!ingestForRules.isEmpty()) {
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: requested"));
const auto rules = QtMeshCloudClient::fetchRules(ingestForRules);
if (rules.ok) {

Check failure on line 2061 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2NsoTKrpw_FOb5OlIO&open=AZ2NsoTKrpw_FOb5OlIO&pullRequest=287
config = ScanConfig::fromJson(rules.config);
err() << "Note: Using QtMesh Cloud rules (source: " << rules.source << ")." << Qt::endl;
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: ok source=%1").arg(rules.source));
} else {
err() << "Warning: Could not load QtMesh Cloud rules (" << rules.errorString
<< "). Using built-in defaults." << Qt::endl;
config = ScanConfig::defaults();
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString),
QStringLiteral("warning"));
}
} else {
config = ScanConfig::defaults();
}
}
}

// CLI overrides
Expand Down Expand Up @@ -2143,9 +2216,18 @@
})
: ScanEngine::AssetProcessedCallback());

if (jsonOutput) {
for (const auto& f : result.findings) {
if (f.rule == QLatin1String("load_error"))
err() << "Load error (" << f.file << "): " << f.message << Qt::endl;
}
}

const QJsonObject reportJson = ScanEngine::scanReportToJsonObject(result);

// Output to terminal
if (jsonOutput) {
cliWrite(ScanEngine::formatJson(result) + "\n");
cliWrite(QString::fromUtf8(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)) + "\n");
} else {
cliWrite(formatScanSummary(result, colorizeTextOutput));
}
Expand All @@ -2155,7 +2237,7 @@
QFile f(reportPath);
QDir().mkpath(QFileInfo(reportPath).path());
if (f.open(QIODevice::WriteOnly | QIODevice::Text))
f.write(ScanEngine::formatJson(result).toUtf8());
f.write(QJsonDocument(reportJson).toJson(QJsonDocument::Indented));
else
err() << "Warning: Could not write report to " << reportPath << Qt::endl;
}
Expand All @@ -2177,7 +2259,7 @@
if (config.reportFormat == "text")
f.write(ScanEngine::formatText(result, config, false).toUtf8());
else
f.write(ScanEngine::formatJson(result).toUtf8());
f.write(QJsonDocument(reportJson).toJson(QJsonDocument::Indented));
}
}
if (sarifPath.isEmpty() && !config.sarifOutput.isEmpty()) {
Expand All @@ -2187,11 +2269,44 @@
f.write(ScanEngine::formatSarif(result).toUtf8());
}

// Exit code based on fail_on threshold
if (config.failOn == "never") return 0;
if (config.failOn == "error" && result.errors > 0) return 1;
if (config.failOn == "warning" && (result.errors > 0 || result.warnings > 0)) return 1;
if (config.failOn == "info" && (result.errors > 0 || result.warnings > 0 || result.infos > 0)) return 1;
bool uploadOk = true;
const QString ingestToken = resolveIngestToken(tokenArg);
if (!ingestToken.isEmpty() && !noUpload) {

Check warning on line 2274 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "ingestToken" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ2NsoTKrpw_FOb5OlIN&open=AZ2NsoTKrpw_FOb5OlIN&pullRequest=287
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud uploadScan: posting"));
const auto up = QtMeshCloudClient::uploadScanReport(ingestToken, reportJson);
uploadOk = up.ok;
if (up.ok) {
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud uploadScan: ok HTTP %1").arg(up.httpStatus));
} else {
SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"),
QStringLiteral("QtMesh Cloud uploadScan: failed HTTP %1").arg(up.httpStatus),
QStringLiteral("warning"));
const QString prefix = strictUpload ? QStringLiteral("Error: ")
: QStringLiteral("Warning: ");
err() << prefix << "QtMesh Cloud scan upload failed (HTTP " << up.httpStatus << "): "
<< up.errorString;
if (!up.responseBodySnippet.isEmpty()
&& !up.errorString.contains(up.responseBodySnippet)) {
err() << " — " << up.responseBodySnippet;
}
err() << Qt::endl;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return 0;
// Exit code from fail_on threshold (scan/lint outcome)
int scanExit = 0;
if (config.failOn != "never") {
if (config.failOn == "error" && result.errors > 0)
scanExit = 1;
else if (config.failOn == "warning" && (result.errors > 0 || result.warnings > 0))
scanExit = 1;
else if (config.failOn == "info"
&& (result.errors > 0 || result.warnings > 0 || result.infos > 0))
scanExit = 1;
}
if (strictUpload && !uploadOk)
return 1;
return scanExit;
}
Loading
Loading